Effects
How Sakura Lang records every side effect and every expected failure in the function signature, and how effect rows compose.
Pre-alpha
The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.
An effect is anything a function does besides compute its return value: read the clock, print, query a database, call the network, fail. In Sakura Lang, every effect a function may perform is listed in its signature, after !.
fn signup(email: Email, pw: Password) -> User ! {Db, Clock, Random, Fail<SignupError>}The braces hold the function's effect row. signup returns a User and may also use Db (a user-defined effect, declared below), the clock, and randomness, and may fail with a SignupError. The row is an upper bound: the body may do less, never more.
Pure functions
A signature with no ! is pure: ! {}. This is the one meaning of omission, for top-level signatures and for written function types alike.
fn total(prices: List<Int>) -> Int {
List.fold(prices, 0, fn(acc, p) { acc + p })
}! {} means "no declared operations and no expected failures". It does not mean "cannot crash": overflow and contract violations are defects, which are not effects.
Performing effects
An operation is performed with Effect.op(args). Calling a function performs that function's row. Creating a closure performs nothing; calling it performs its row.
fn greet(name: String) -> Unit ! {Console, Clock} {
let now = Clock.now()
Console.print("hello, {name}; it is {Instant.format_iso(now)}")
}
pub fn main() -> Unit ! {Console, Clock} { greet("Sakura Lang") }main calls greet, so main's row must include Console and Clock.
Declaring effects
An effect declares operations as function signatures, with no bodies:
error DbUnavailable(reason: String)
effect Db {
fn exists(table: Table, key: String) -> Bool
fn insert(table: Table, row: Json) -> Unit ! {Fail<DbUnavailable>}
}An operation's row may contain Fail<…>, plus row variables of the operation's own type parameters that also appear in a function-typed parameter, so an operation that runs a callback performs what the callback performs (fn scope<T, e>(body: fn(Scope) -> T ! e) -> T ! e). Any other label is V3010. The declaration says what operations exist, not what they do. That is the job of a handler, chosen by the caller.
Rows are sets
A row is a set of unique labels plus an optional open tail: {Db, Clock | e}. A label is an effect plus its type arguments, such as Fail<SignupError>. Order does not matter; the formatter sorts labels alphabetically with Fail<…> last. Sequencing two expressions takes the union of their rows. See design §5.2 for the unification rules.
Effect polymorphism
A higher-order function should not fix the effects of the function it receives. A lowercase type parameter is an effect-row variable:
fn map<A, B, e>(xs: List<A>, f: fn(A) -> B ! e) -> List<B> ! eIf f is pure, the call is pure. If f logs and may fail, the call logs and may fail. An open row names some labels and leaves the rest to a variable:
fn traced<T, e>(name: String, body: fn() -> T ! e) -> T ! {Log | e} {
Log.event(Level.Info, "start {name}", [])
let value = body()
Log.event(Level.Info, "end {name}", [])
value
}A top-level function must declare its row variables (<e>). A function value with a closed row is accepted where a larger row is expected: fn() -> Int ! {Clock} can be passed where fn() -> Int ! e is expected, and e becomes {Clock}. This widening is one of the three places Sakura Lang has subtyping, with error unions and refinement-to-base.
Failure: Fail<E> and error unions
There are no exceptions. Expected failure is Fail<E>, the one compiler-known effect. E is an error union: a set of nominal error tags.
error EmailTaken(email: Email)
error Banned
type SignupError = EmailTaken | Banned // union alias: tags onlyfail EmailTaken { email } has type Never and adds Fail<EmailTaken> to the current row. Fail occurs at most once in a row. When two failing expressions are sequenced, their unions join: Fail<A> ∪ Fail<B> = Fail<A | B>.
Propagation is automatic. Calling a function with Fail<A> adds Fail<A> to the caller's row, with no operator needed. A union U is accepted where V is expected if U ⊆ V. When two different unions meet with no expected type, the compiler asks for an annotation (V2015).
Recovery uses try/catch. Arms are patterns over the body's tags. Tags matched by an unguarded arm are removed from the row; unmatched tags propagate:
error NotFound(id: String)
error Forbidden
error Unavailable(reason: String)
type UserError = NotFound | Forbidden
type User = { id: String, owner: String, name: String }
effect Users {
fn get(id: String) -> Option<User> ! {Fail<Unavailable>}
}
pub fn view_user(caller: String, id: String) -> User ! {Users, Log, Fail<UserError>} {
let user = try {
Users.get(id)
} catch {
Unavailable { reason } => {
Log.event(Level.Warn, "users backend unavailable", [("reason", Json.String(reason))])
None
}
}
match user {
None => fail NotFound { id },
Some(u) if u.owner != caller => fail Forbidden,
Some(u) => u,
}
}The internal Unavailable tag is translated at the boundary, so the public union stays small and stable. Other ways to move between failure and data:
| Form | Result |
|---|---|
try { body } with no catch | Result<T, E> with the body's full union; Fail leaves the row |
r? on r: Result<T, E> | T, and adds Fail<E> to the row |
Option.or_fail(o, err) | the value, or fail err on None |
? applies only to a Result. A call to a function with Fail<E> in its row needs no operator; its failure propagates on its own.
A try body cannot assign to a var declared outside it (V2041), because the body may stop at any failing call and leave the assignment half done. Its value and its error are the only things that leave it: assign after the try, or declare the var inside.
There is no implicit conversion between error types. A catch-all _ => … arm is allowed but discouraged on open unions (W4010).
Defects are not effects
A defect is a bug, not an expected failure. It terminates the current task after running resource finalizers. try cannot catch it. Task.scope and the test runner observe it as Outcome.Defect(info), and main ending in a defect exits with status 70 and writes a defect record to stderr. By contrast, a typed failure that escapes main exits with status 1 and prints error: main failed with <Show of the error>.
| Code | Defect |
|---|---|
D001 | Int overflow |
D002 | division by zero |
D003 | Int beyond ±(2^53−1) on the JavaScript backend |
D010 | failed checked introduction, such as Email.from(s) |
D020 / D021 | requires violated (caller blamed) / ensures violated (callee blamed) |
D030 | Option.expect on None |
There is no user-callable way to raise a defect. Express a programming error with requires or Option.expect.
Standard effects
From design §10.2. Each effect lives in its own std module (std/console, std/http, …). The first seven are in the prelude; the rest must be imported by name, for example import std/http.{Http}.
| Effect | Operations | Purpose |
|---|---|---|
Console | print, read_line | Terminal IO |
Clock | now, monotonic, sleep | Time |
Random | int, float, bytes, uuid | Randomness |
Env | get, secret | Environment variables: get returns Option<String>, secret returns Option<Secret<String>> |
Log | event | Structured logging |
Task | scope, spawn, join, all, race, yield_now | Structured concurrency |
Test | record_failure, output_lines, snapshot, … | Assertions inside test and property bodies |
Fs | read, read_text, write, list, exists, with_file | Files |
Http | send | HTTP client |
Serve | listen | HTTP server |
Sql | query, execute, transaction | SQL database |
Cli | args, exit | Command-line arguments and exit status |
Budget | reserve, settle | Token, cost, and call limits |
Llm | generate, generate_text, stream | Language model calls |
Fail<E> | fail | Expected failure |
There is no Async effect. Every Sakura Lang function may suspend; whether a call suspends is decided by its handler (design §5.6). See Concurrency.
Inference
Inside a body, the checker infers a row for every expression together with its type (design §5.7):
| Form | Row |
|---|---|
literal, variable, lambda, constructor, handler {…} | {} |
f(a1..an) | rows of f and the arguments, plus f's latent row |
Eff.op(args) | rows of the arguments, plus {Eff} and the op's declared Fail |
fail e | row of e, plus Fail<tag(e)> |
if / match | condition or scrutinee, plus the union of all branches |
with h1..hn { b } | row of b minus the handled effects, plus each handler's row |
try { b } catch { arms } | row of b with matched tags removed, plus the arms' rows |
try { b } | row of b minus Fail |
r? | row of r, plus Fail<E> |
Branch rows join conservatively: an effect in an unreachable branch still counts. Evaluation is strict and left to right.
Top-level rows are never inferred. Every top-level fn states its row, so a change in a callee's effects cannot silently change a caller's contract.
Undeclared effects
If a body performs an effect its signature does not allow, sakura check reports V3001:
type User = { id: String, name: String, seen: Instant }
effect Db {
fn get_user(id: String) -> Option<User>
}
pub fn load_user(id: String) -> Option<User> ! {Clock} {
let now = Clock.now()
let found = Db.get_user(id)
Option.map(found, fn(u) { User { ..u, seen: now } })
}sakura check --json emits one JSON Lines record per diagnostic, in the design §12.2 shape. Pretty-printed, with hashes shortened:
{
"schema_version": "1.0",
"kind": "diagnostic",
"id": "d:7f3a…",
"code": "V3001",
"severity": "error",
"phase": "effect",
"message": "function `load_user` performs `Db`, which its signature does not allow",
"snapshot": "sha256:…",
"symbol_id": "pkg::users/repo::load_user",
"spans": [
{
"uri": "file:///…/src/users/repo.sakura",
"content_hash": "sha256:…",
"start_byte": 159,
"end_byte": 168,
"role": "primary",
"label": "allowed here: `{Clock}`"
},
{
"uri": "file:///…/src/users/repo.sakura",
"content_hash": "sha256:…",
"start_byte": 209,
"end_byte": 224,
"role": "cause",
"label": "`Db.get_user` introduces `Db` here"
}
],
"related_ids": [],
"expected": {
"render": "{Clock}",
"row": { "labels": [{ "effect": "std::Clock", "args": [] }], "tail": null }
},
"actual": {
"render": "{Clock, Db}",
"row": {
"labels": [
{ "effect": "std::Clock", "args": [] },
{ "effect": "pkg::users/repo::Db", "args": [] }
],
"tail": null
}
},
"obligation": null,
"counterexample": null,
"explanation_uri": "sakura://explain/V3001",
"fixes": [
{
"id": "f:1",
"title": "Add `Db` to the signature of `load_user`",
"applicability": "suggested",
"impact": "interface_change",
"edits": [
{
"span": {
"uri": "file:///…/src/users/repo.sakura",
"content_hash": "sha256:…",
"start_byte": 159,
"end_byte": 168,
"role": "primary",
"label": "signature row"
},
"replacement": "! {Clock, Db}"
}
]
},
{
"id": "f:2",
"title": "Handle `Db` locally by installing a handler around the call",
"applicability": "incomplete",
"impact": "local",
"edits": [
{
"span": {
"uri": "file:///…/src/users/repo.sakura",
"content_hash": "sha256:…",
"start_byte": 209,
"end_byte": 224,
"role": "cause",
"label": "call to `Db.get_user`"
},
"replacement": "with ?db_handler { Db.get_user(id) }"
}
]
}
]
}Byte offsets are zero-based and half-open. There are two honest ways out. If load_user really should touch the database, the signature changes; that fix is an interface_change, so it is never machine_applicable and a reviewer sees it. If it should not, the call is handled locally; that fix leaves a typed hole for the handler, so it is incomplete. See Diagnostics.
Effects are requirements
Fs in a row says "may perform filesystem operations". It does not say which files. The installed handler decides that, and handlers are built from capability values the program cannot forge. See Capabilities and security.
What the compiler checks
- The inferred row of a body is a subset of the declared row. Each extra label is
V3001, with the call that introduced it. - A label that a closed row does not permit is
V3002. - Every
Failtag is either caught or declared. Union mismatches without an expected type areV2015. - Effect operations declare only
Failand their callbacks' row variables in their rows (V3010). - Top-level rows and row variables are declared, never inferred.
Next
Handlers: how a caller decides what an effect actually does.