MEP 15. Effects
| Field | Value |
|---|---|
| MEP | 15 |
| Title | Effects |
| Author | Mochi core |
| Status | Draft |
| Type | Standards Track |
| Created | 2026-05-08 |
A note on names
This MEP replaces the previous "Effects, Mutability, and Purity" framing. Mutability is a separate axis (the let versus var distinction, root-binding tracking, MEP-10 A3) and belongs in MEP-10. Effects are about whether a function reads the clock, writes to stdout, hits the network, or is a deterministic value transformer. Conflating the two made both stories harder to read and pushed the previous draft toward a single Pure bit that nobody could decide whether to keep.
So when this document says "effect", read it as "an observable interaction with the world outside the type-erased value graph". When it says "mutability", that lives elsewhere.
Abstract
Mochi adopts a small labeled effect system. Each function value carries a finite set of effect labels drawn from a closed vocabulary: io, fs, net, time, meta. The empty set is pure. The set is inferred from the body as the union of every callee's effect set, plus any statement-level effect the body performs directly. A function may declare an upper bound on its effects with an ! annotation on the signature, in which case the inferred set must be a subset. The same machinery enforces purity for query predicates (already T044 today) and any future context that requires a pure expression (compile-time evaluation, type-level computation, struct field defaults).
The system is intentionally not a row-typed effect calculus. There is no <e> row variable in surface syntax, no algebraic effects, and no handlers. Higher-order functions like map and fold propagate their callback's effect set to the call site through a hidden row variable that the elaborator threads but the printed type never shows. This matches Swift's rethrows discipline and Unison's "abilities" surface, and avoids the row-variable-everywhere noise that Koka users live with.
The binary Pure bool field on FuncType is replaced by an Effects EffectSet. Every call site that reads ft.Pure continues to work through a thin compatibility method (ft.Pure() = ft.Effects.IsEmpty()). The change is mechanical at stage 1 and the existing T044 fires unchanged. Stage 2 adds the surface annotation. Stage 3 widens predicate diagnostics to name the offending effect labels and extends T064 / T065 to anonymous functions. Stage 4 (deferred) reconsiders user-defined effects and handlers if and when async or exceptions land.
Motivation
Mochi already has a Pure flag. It is set on roughly forty builtins, inferred on every user function via isPureFunction, read at exactly two enforcement points (where and having predicates) and one optimization (compile-time constant folding for pure user functions in runtime/vm/vm.go), and consulted by MEP-16 N5 to drop narrowing across non-pure calls. The flag works. It is also a single bit. It cannot answer the questions that come up the moment you try to write any non-trivial system:
- "Does this function touch the network?" The Pure bit says yes-or-no, no-and-also; you cannot tell
printapart fromfetch. - "Is it safe to memoize this?" Needs
timeandrandomdistinguished fromio. - "Is this safe to run at compile time?" Needs the absence of
ioandfsandnet, plus the absence ofmeta(eval). - "Did this hot loop accidentally pick up an
ioeffect because someone added aprintfor debugging?" The Pure bit flips and you lose query-predicate eligibility for the whole call chain. With a labeled set, the change is visible and granular. - "What can I prove about a callback I am passing into
map?" With a binary flag, nothing. With a label set, the call inherits exactly the callback's effects, no more and no less.
The labels Mochi needs at stage 1 are not interesting. Look at the existing builtin surface (audit findings in the implementation section below): print, json, input, now, eval, plus three statement-level constructs (fetch, load, save). Five labels cover all of them and leave room for random and throw when those land. The cost of the upgrade is a struct field rename, a bitset type, a five-line table of which builtin gets which label, and a recursive walker that already exists.
The upside is that every future enforcement point gets to ask the question it actually wants to ask. T044 keeps asking "is this empty". A future "is this safe to evaluate at compile time" asks "is this a subset of pure". A future "is this safe to push through a join" asks "does this lack time and random". The vocabulary scales linearly with the language; the type-checker plumbing does not change.
Status at a glance
| Item | Status |
|---|---|
Pure bool on FuncType (single bit) | closed (legacy) |
isPureFunction body inference | closed |
T044 in where and having predicates | closed |
| Compile-time constant folding gated on Pure | closed |
| MEP-16 N5 narrowing drop on non-pure call | closed |
Effects EffectSet field on FuncType | closed (Stage 1) |
Closed label set: io, fs, net, time, meta | closed (Stage 1) |
| Builtin effect annotations | closed (Stage 1) |
| Effect inference (union of callees, plus statement-level effects) | closed (Stage 2a) |
Pure() method as compatibility shim | closed (Stage 1) |
| Effect propagation through higher-order generics | closed (Stage 3d) |
Surface annotation fun foo(): T ! io, fs { ... } | closed (Stage 2b) |
| Diagnostic T065 (declared effect exceeded) | closed (Stage 2b) |
| Diagnostic T066 (effect not allowed in context) | closed (reserved, no firing site until const lands) |
| Effect inference for closures and intent methods | closed (Stage 3b) |
| User-defined effect labels | by design (deferred) |
Algebraic effect handlers (try / with / resume) | by design (deferred) |
Specification
Surface
There is no new keyword at stage 1. Effect annotations land at stage 2 with a single new piece of syntax: ! after the return type, followed by a comma-separated list of labels.
fun greet(name: string): unit ! io {
print("hi " + name)
}
fun pure_add(a: int, b: int): int { // no annotation: inferred pure
return a + b
}
fun load_config(path: string): map<string, any> ! fs {
return load("yaml", path)
}
The annotation list is unordered and de-duplicated. An empty list is illegal; write no annotation for that. A bare ! pure is also illegal because the absence of the annotation already says pure. Both restrictions exist so there is one and only one way to write each case.
For function expressions, the annotation comes between the parameter list and the body:
let log = fun(msg: string): unit ! io { print(msg) }
Lambdas without an annotation infer their effects from the body, same as a fun declaration.
Effect labels
The label vocabulary is closed. Stage 1 ships five labels. Each maps to a concrete reason a function leaves the pure-value world:
| Label | Meaning | Examples |
|---|---|---|
io | Writes to stdout or reads from stdin | print, json, input |
fs | Reads or writes the local file system | load, save |
net | Network I/O | fetch |
time | Reads the wall clock or other non-deterministic clock | now |
meta | Dynamic compilation or evaluation | eval |
time exists separately from io because a where predicate that reads time is unsafe for the query optimizer to reorder even though it never touches stdout. meta exists separately because compile-time evaluation must reject eval even when stdout is fine.
Two labels deliberately not in stage 1:
statefor "mutates through a capturedvar". Mutability is tracked by MEP-10 A3 today through root-binding analysis. Re-introducing it as an effect would force every function that uses avarcounter to acquire a label, which inflates annotations without enabling new enforcement. Revisit whenmutbecomes worth a separate axis.throwfor "may fail at runtime via a non-pure error path". Mochi has no exceptions today. The label lands when exceptions land.
random will land at the same time as a built-in RNG. Stage 1 has no random source other than now, so the label is not justified yet.
User-defined labels are out of scope at stage 1. Koka's complexity budget went almost entirely into making user-defined effects compose, and that complexity is not justified for a query-and-pipeline language until handlers also land. Re-evaluate as part of the exceptions / async MEP.
Type rules
Let EffectSet be a finite set drawn from the closed label vocabulary. pure is the empty set. Set inclusion S1 <= S2 means every label of S1 is in S2.
E1. Function arrows carry an effect set
The type of a function value is (P1, ..., Pn) -> R ! S where S is an EffectSet. When S is empty the arrow is pure and the suffix is dropped from the printed form: (int, int) -> int.
E2. Inference: body union
For a user function without a declared effect annotation, the inferred effect set is the union of:
- The effect set of every callee invoked in the body (transitively via the callee's own inferred or declared set).
- The effect set induced by every statement-level effect performed directly:
fetchcontributesnet,loadandsavecontributefs, anupdatestatement contributes whatever effects MEP-14 ascribes to it (today, none beyondstatewhich is not tracked). - The effect set of every closure constructed inside the body, if and only if the closure is invoked synchronously within the same function. A closure stored or returned does not contribute its effects to the enclosing function; the act of running the closure does, at the call site.
The recursion is well-founded because the call graph is finite and Mochi has no mutually recursive function-typed bindings without an explicit type annotation that fixes the effect set.
E3. Annotation: subset check
For a user function with an ! S_declared annotation, the inferred effect set S_inferred must satisfy S_inferred <= S_declared. If S_inferred is a strict subset, the function still has effect S_declared from outside; the declaration is an upper bound, not a description.
If S_inferred is not a subset of S_declared, raise T065.
The declaration-as-upper-bound rule lets a public function commit to "I touch the filesystem" without breaking when an internal helper grows a new effect. It also matches Swift typed throws and Java checked exceptions in the direction that worked: callers see what the callee promised, not what it happens to do today.
E4. Subtyping on arrows
(P1, ..., Pn) -> R ! S1 is a subtype of (P1, ..., Pn) -> R ! S2 when S1 <= S2. The parameters and return type follow the usual variance rules from MEP-12.
This rule is what makes "a pure function can be passed wherever an io function is accepted" work. The other direction is illegal: an io value does not flow into a pure slot.
E5. Polymorphism through higher-order generics
Higher-order generic functions propagate their callback's effect set to the call site through an implicit effect parameter. For
fun map<T, U>(xs: list<T>, f: (T) -> U): list<U> {
var ys: list<U> = []
for x in xs {
ys = append(ys, f(x))
}
return ys
}
the call map(xs, fun(x) { print(x); return x }) has effect set {io} even though map itself is pure. The effect set of a call is the union of the function's own effect set and the effect sets of every function-typed argument that is invoked in the body.
The elaborator threads this through a hidden effect parameter on each function-typed parameter. The user never writes the parameter and never sees it in error messages; it exists only in the elaborator's intermediate form. This is the same design as Unison's abilities and Swift's rethrows, both of which ship the propagation without ever printing the row variable.
A function-typed parameter that is stored (assigned to a local, returned, put into a collection) does not propagate its effects to the surrounding call. Only the call of the parameter does. This matches MEP-16 N6's closure boundary rule on narrowing: storage defers, invocation commits.
E6. Closure construction
Constructing a closure does not produce effects. The closure's type carries its body's inferred effect set, which is observed at the call site. This is what makes let log = fun(msg: string) { print(msg) } a pure assignment that nonetheless yields a value of type (string) -> unit ! io.
E7. Effect set normalization
Two effect sets that contain the same labels are equal regardless of how they were built. Stage 1 enforces a canonical form (sorted by the fixed label index) so that printed types are stable across compilations.
E8. Statement-level effects
Statement constructs contribute their effects to the enclosing function:
| Statement | Effects |
|---|---|
fetch ... | net |
load ... | fs |
save ... | fs |
update ... | (none at stage 1) |
print ... | not a statement; goes through the print builtin call |
Statement-level effects are added to the enclosing function's inferred effect set in the same pass as call effects.
E9. Pure positions
A pure position is any expression whose effect set must be empty. Stage 1 has two pure positions, both inherited from existing behavior:
whereandhavingpredicates (already T044).- (Future, see Open Questions:) compile-time-evaluable expressions, type-level computation arguments, struct field defaults, and
constdeclarations once those land.
A non-empty effect set in a pure position raises T044 with a label-aware help message: "%s performs effect %s, but %s requires a pure expression". The diagnostic stays T044 because the user-visible failure is the same; the label list lands in the help text.
E10. Effect annotation context
A declared effect set bounds what the function may do, not what its callers may observe. A caller that calls a function declared ! io, fs and uses only its pure return value cannot conclude the call was pure: the act of invoking the function is itself in the effect set, and the surrounding function's inferred set unions in {io, fs}.
Effect inference
The inference pass runs once per function declaration, in dependency order over the call graph:
- The pre-pass that registers function signatures (
check.gofinal pre-pass) is extended to produce anEffectSetper declared function. Initially empty for forward-declared functions, refined when the body is type-checked. - The body walker (already inside
isPureFunction, generalized toinferFunctionEffects) walks the AST collecting:- Callee effects, looked up from
FuncType.Effects. - Statement-level effects from
s.Fetch,s.Load,s.Save, futures.Throw. - Closure-construction sites (no contribution; only call sites count).
- Callee effects, looked up from
- The collected set is stored on the function's
FuncType.Effectsin the surrounding env. - If a declared annotation is present, the subset check from E3 fires here.
The walker is monotone: a function never loses an effect once added. Top-level signatures iterate to a fixpoint in check.go so mutual or forward references converge; the bitset lattice is finite (at most 1 << effectMax states) so termination is guaranteed in at most effectMax sweeps of the dependency graph. The union operation is idempotent.
Cost: one extra EffectSet field per FuncType, one bitwise OR per call site, one subset check per declared annotation. No measurable impact on type-checking time.
Effect polymorphism in practice
The hidden effect parameter on higher-order generics is best illustrated by the fixtures that ship with stage 2:
// inferred: pure
fun apply_pure(xs: list<int>, f: (int) -> int): list<int> {
var ys: list<int> = []
for x in xs {
ys = append(ys, f(x))
}
return ys
}
// caller 1: call site is pure
let doubled: list<int> = apply_pure([1, 2, 3], fun(x: int): int { return x * 2 })
// caller 2: call site has effect {io}
let echoed: list<int> = apply_pure([1, 2, 3], fun(x: int): int { print(x); return x })
The function type of apply_pure is printed as (list<int>, (int) -> int) -> list<int>. There is no row variable in the printed form. Internally the elaborator records that f's effects flow into the result. The call-site effect for echoed is {io}. The call-site effect for doubled is {}.
This composes with type generics in the obvious way: the row variable rides along with each function-typed parameter, independently of the type variables.
Diagnostics
Two new error codes plus a generalized one:
- T044 (existing): "impure call to
%sis not allowed in%spredicate". Now reads the effect set instead of the binary flag; help text mentions which labels are present. - T065 (new): "declared effect set
! %sdoes not cover inferred effects%s". Fires when a function body uses an effect not listed in its!annotation. Help text names the missing label and the call site that contributed it. - T066 (new, reserved): "expression produces effect(s) %s, not allowed in %s". The diagnostic template, constructor (
errEffectInPurePosition), and message contract are pinned by tests as of Stage 3c. No surface emits it yet;constdeclarations and struct field defaults will be the first call sites when they land.
The error messages name labels with the same lowercase identifiers users wrote, never internal indices or codes.
Runtime model
Effects are erased at typecheck time. The runtime has no knowledge of effect sets. The VM continues to evaluate calls in source order; effects are not used to reorder, parallelize, or short-circuit at runtime. The only runtime change is that runtime/vm/vm.go constant folding switches its gate from ft.Pure to ft.Effects.IsEmpty(), which is equivalent today.
When and if the query optimizer becomes aggressive enough to push predicates across joins (a stage-4 MEP-14 follow-up), the empty-set check will be its license to reorder. Until then, the runtime is unchanged.
Effect annotation context for closures
A closure's effect set is observed at its call site. A closure that captures an io-effectful free variable does not gain an effect from the capture; the capture is a name lookup, not an invocation. The closure gains io if its body calls a function with io in its effect set, period. This matches E5 / E6 and avoids the "lifetime virality" that Scala 3 capture checking inherits from its first-class capability model.
Interaction with MEP-16 N5
MEP-16 N5 currently drops narrowing for var bindings after any call to a non-pure function. Under the labeled scheme, the same rule applies with the new gate: a call with a non-empty effect set drops var narrowing. The conservative semantics are unchanged.
Stage 4 (or later) may refine N5 to drop only when the callee carries a hypothetical state label, but that requires the state label to exist first. Until then the wider drop is the safe default.
Backwards compatibility
The runtime change is a no-op. The static behavior is a strict refinement: every program that type-checks today continues to type-check at stage 1 because the empty-versus-non-empty distinction is preserved bit-for-bit.
Stage 2's surface annotation is opt-in. A function without ! continues to infer.
Stage 2's subset enforcement (T065) is a breaking change for any function whose declared annotation is too narrow. The migration is to widen the annotation or to remove the call that contributed the unexpected effect. The fixture set captures both flavors.
Rationale
Why a closed label set rather than user-defined effects
Closed labels are checked in O(1). User-defined effects need a registration mechanism, scoping rules, and visibility (is an effect from another module the same as a same-named one here?). Koka's machinery answers all of this and the answers are subtle. The payoff of user-defined effects is the ability to write handlers, which Mochi does not have. Pay the cost when the payoff lands.
The label vocabulary is short because the language is short. Stage 1 has five labels for five concrete kinds of side effect that the existing builtin surface exercises. Future MEPs add a label whenever they add a new family of builtin: random, exceptions, async, mutation-as-effect.
Why no row variables in printed types
Row variables in printed types are the single biggest complaint about Koka and the reason Frank papers spend so much ink on the bidirectional discipline. The mainstream-syntax languages that ship effect tracking (Swift, Java, Unison) all hide the row variable from the user. The hidden row is type-theoretically present (any sound implementation of higher-order polymorphism needs it), it just does not surface.
The Mochi printed form (list<T>, (T) -> U) -> list<U> is the same printed form a user without effect tracking would expect from MEP-12.4. The effect propagation happens at the call site, not in the signature. The trade-off is that a generic helper cannot statically reject a callback with a given effect without an explicit annotation; that case is the long-tail and we accept the limitation.
Why annotations are optional
Mainstream-syntax languages that require effect annotations everywhere ([Eff], [Koka]) report user complaints about ceremony. Inference is cheap, and the union-by-callee model gives a precise answer for free. Annotations exist for two reasons: documentation at module boundaries, and a soft brake on accidental effect creep (T065). Neither is necessary for soundness; the inferred set is the source of truth.
Why not just keep Pure bool
The single-bit flag works for where/having and for the MEP-16 N5 narrowing drop. It does not work for any of the questions in the Motivation section. Replacing it is a mechanical rename behind a compatibility shim. Keeping it ossifies the binary distinction and burns the design space for future enforcement points.
Why erase at runtime
Mochi has no algebraic effect handlers. Without handlers, the runtime has nothing to do with effects: there is no try, no with, no resume. Effects are pure metadata. Erasing them is the simplest model and the one every effect system that lacks handlers (Swift, Unison-abilities-without-handlers, mtl-without-Eff) uses.
When handlers land (stage 4+, deferred), the runtime gets a perform and handle primitive. The label set acquires user-defined entries. The erasure rule changes for those specific labels and stays the same for the closed builtin set. That is a future MEP's problem.
Why label time and meta separately from io
time and meta are not stdout. The query optimizer's licence to reorder depends on the absence of time even when stdout is fine. A future "compile-time evaluation" pass must reject meta even when neither io nor time is present. Collapsing them into a single io would force every enforcement point to special-case lookups and would lose the precision the design pays for.
The five labels are chosen so that no single label subsumes another except in trivially obvious ways (fs and net and io are all distinct interactions with the outside world). The label graph is flat. There is no inheritance, no hierarchy, no io.read versus io.write split. Both granularities have been tried in research languages; both produce more bugs than they prevent at the level of polish Mochi targets.
Alternatives considered
Algebraic effects with handlers ([Eff], [Koka], [OCaml 5]). Powerful: user-defined effects, multi-shot continuations, structured concurrency. The cost is a runtime that supports delimited continuations (one-shot or multi-shot) plus a typed surface that mainstream users find foreign. OCaml 5 shipped the runtime without the types and the community treats it as a library substrate, not a user-facing facility. Sivaramakrishnan et al., Retrofitting Effect Handlers onto OCaml (PLDI '21), explicitly defers typing. Mochi would inherit the same dilemma. Defer to stage 4 or beyond.
Effect rows in every signature ([Koka]). Daan Leijen, Koka: Programming with Row-Polymorphic Effect Types (MSR-TR-2014-23) and Type Directed Compilation of Row-Typed Algebraic Effects (POPL '17). Most expressive design. The cost is row variables everywhere in user-visible types and error messages that mention rows the user never wrote. Mochi targets mainstream readability; the surface tax is too high.
Capabilities as values ([Scala 3 capture checking], [Effekt]). Brachthäuser, Schuster, Ostermann, Effects as Capabilities (OOPSLA '20), and Odersky's draft A Type System for Capabilities (2022 onwards). Effects modeled as first-class values with capture-set inference. Compelling for lifetime tracking and for replacing async coloring, but the user-visible cost (the ^ annotation and capability-not-in-scope errors) is high for a language without mut lifetime tracking. Revisit if Mochi grows a borrowing discipline.
Monomorphic IO monad ([Haskell]). Hard wall between effectful and pure code. Solves the binary case at the cost of monadic plumbing in every effectful function. Mochi has no monad and no do notation. Adopting IO would require both. The label-set design above gives the same separation without the plumbing.
Bidirectional effect inference ([Frank]). Lindley, McBride, McLaughlin, Do Be Do Be Do (POPL '17). Effects flow in via the expected type, out via the actual type. Eliminates row variables in printed types but requires annotations at every "mode switch" (a function-typed parameter, a higher-order call). Mochi prefers full inference with optional annotations.
Effect rows with hidden surface ([Unison] abilities, [Swift] rethrows). The implementation strategy this MEP picks. The row variable exists in the elaborator; the printed type hides it. Calls inherit the row from their function-typed arguments. This is the sweet spot for a mainstream-syntax language with HOFs and generics.
Checked exceptions only ([Java], [Swift] throws). Monomorphic effect tracking. Java's checked exceptions are widely regarded as a failure because callbacks (Runnable, Stream.map) cannot propagate them, forcing RuntimeException wrappers. Swift fixed the lambda case with rethrows and later typed throws. The lesson: monomorphic effect tracking without higher-order polymorphism breaks at the first map. Mochi adopts the labeled-set design with the rethrows-style propagation precisely to avoid this failure mode.
Migration staging
The work lands in three stages, each a separate PR with fixture pinning:
-
Stage 1 (shipped, PR #21443): replace the bit. Introduce
EffectSet, addEffects EffectSettoFuncType, tag every builtin with its label set, keep thePure()method as a compatibility shim, regenerate goldens. No surface syntax. T044 keeps firing for non-empty effect sets inwhere/having. MEP-16 N5 keeps firing for any non-empty effect set. -
Stage 2a (shipped): real body-walked inference.
inferFunctionEffectswalks each function body to union the effects of every callee and statement-level construct it reaches. Top-level signatures iterate to a fixpoint so mutual and forward references converge. ThelegacyEffectsFromPurebridge is removed. -
Stage 2b (shipped): surface annotation. Add the
!syntax toFunStmtandFunExprin the parser. The annotation is parsed as a list of label idents, then converted to anEffectSetintypes/check.go. The declared set is treated as the published upper bound: callers see the declared value onFuncType.Effects, and the body walker's inferred set must be a subset (else T065). Unknown labels surface T064. Fixtures:tests/types/valid/effects_annotation_accepted.mochi,tests/types/errors/effects_declared_exceeds_inferred.mochi,tests/types/errors/effects_unknown_label.mochi. -
Stage 3a (shipped, PR #21449): T044 names the effect labels. The predicate diagnostic now reads
call to \print` produces effect(s) io, not allowed in `where` predicateinstead of the binaryimpure call. Help text suggests pre-computing the value or hoisting the call outside the query.firstImpureCalland helpers thread the callee'sEffectSetthrough toerrImpurePredicate`. -
Stage 3b (shipped): FunExpr annotation diagnostics. Anonymous function expressions (
fun(x) ! io => print(x)) now run the same T064 / T065 validation as named functions.parseDeclaredEffectsis split so bothFunStmtandFunExprshare the label parser. A newinferFunExprEffectswalks bothBlockBodyandExprBodysymmetrically. The closure'sFuncType.Effectsis stamped with the declared set when present, or with the inferred set when omitted, so callers see the same upper-bound contract regardless of whether the function is named or anonymous. -
Stage 3c (shipped): reserve T066. The pure-position diagnostic template is registered in
types/errors.go, theerrEffectInPurePosition(pos, context, effects)helper is in place, and a unit test pins the rendered message and help text. No surface emits T066 yet; the helper will be wired up whenconstdeclarations and struct field defaults land. This stage exists so the diagnostic code, message wording, and constructor signature are settled before the consuming features go through review. -
Stage 3d (shipped): HOF callback effect propagation. A new
callableEffects(e, env)helper intypes/effects_infer.godetects the two "bare callable" argument forms, a FunExpr literal and a simple identifier of function type, and returns the effects that calling that value would incur. BothprimaryEffects(direct callsf(args)) andpostfixEffects(postfix / method calls) union incallableEffectsfor each argument. This meansapply(fun(x:int):int{ print(x); x }, 5)andapply(do_print, 5)are both correctly typed as!ioat the call site, regardless ofapply's own declared effects. The approximation is conservative: if an argument could be a function value, its effects flow to the call site. No false negatives; occasional false positives for HOFs that store rather than call their argument are accepted as the sound over-approximation. -
Stage 4 (deferred): decide on user-defined effects and handlers. Likely paired with the exception MEP or the async MEP. Out of scope here; the design is intentionally extensible.
The stages can ship months apart. Stage 1 is the only one that touches the type checker. Stage 2 is parser + a single subset check. Stage 3 is diagnostic polish. Stage 4 is a separate MEP.
Reference implementation
Pointers to where each piece lives once the stages ship.
types/kinds.goFuncType.Effects: replacesPure bool;Pure()method returnsEffects.IsEmpty().types/effects.go(new):EffectSettype (sorted bitset over the closed label index),Union,IsSubset,IsEmpty,String,ParseLabel.types/check.gobuiltin block: each existingPure: truebecomesEffects: EmptySet(); impure builtins acquire labels (print: io,now: time,eval: meta, etc.).types/pure.gorename totypes/effects_infer.go:isPureFunctionbecomesinferFunctionEffects.firstImpureCallbecomesfirstEffectfulCall, returning the label list at the offending site for better error help text.types/check_expr.go: T044 site atwhereandhavingpredicates readsEffects.IsEmpty()instead ofPure.types/check.goif-statement narrowing loop (MEP-16 N5 hook): readsEffects.IsEmpty().runtime/vm/vm.goconstant-folding gate: readsEffects.IsEmpty().parser/ast.goFunStmt.Effects:[]stringpopulated by stage 2 parser. Empty at stage 1.types/errors.go: T044 help text gains label list; T065 added; T066 reserved witherrEffectInPurePositionconstructor.types/effects_infer.gocallableEffects: detects bare-callable arguments (FunExpr literal or identifier of function type) and returns the effects incurred by calling them; wired intoprimaryEffectsandpostfixEffectsarg loops (Stage 3d).
Open questions
constdeclarations and compile-time evaluation. A natural pure position; the design above reserves T066 for it. Land whenconstlands.- Effect inference for struct method defaults. Default expressions in struct field declarations must be pure for the same reason
constmust be. Same gate, different syntax surface. - Effect inference for cross-module imports. Today every Mochi program is single-file. When imports land, the effect set must travel across module boundaries. The label set is closed, so the serialization is a small bitset; no schema work.
- Effect-driven query reordering. The optimizer can push predicates across joins when the predicate is pure. The first version of that work lives in MEP-14 follow-ups; this MEP gives it the gate.
timeversusclockversusnow. The labeltimecovers any wall-clock or monotonic-clock read. A future monotonic-only API would still carrytimebecause non-determinism is the property the optimizer cares about, not the unit of measure.- User-defined labels. Reconsidered together with handlers. Pre-emptively shipping a registration mechanism without handlers is the Koka path; declined here.
- Effect handlers. Strictly deferred. The current runtime cannot implement them; adding delimited continuations is a separate engineering project.
References
- Daan Leijen. Koka: Programming with Row-Polymorphic Effect Types. Microsoft Research TR MSR-TR-2014-23, 2014. https://www.microsoft.com/en-us/research/publication/koka-programming-with-row-polymorphic-effect-types/
- Daan Leijen. Type Directed Compilation of Row-Typed Algebraic Effects. POPL 2017. https://dl.acm.org/doi/10.1145/3009837.3009872
- Sam Lindley, Conor McBride, Craig McLaughlin. Do Be Do Be Do. POPL 2017. https://dl.acm.org/doi/10.1145/3009837.3009897
- Jonathan Brachthäuser, Philipp Schuster, Klaus Ostermann. Effects as Capabilities: Effect Handlers and Lightweight Effect Polymorphism. OOPSLA 2020. https://dl.acm.org/doi/10.1145/3428194
- KC Sivaramakrishnan et al. Retrofitting Effect Handlers onto OCaml. PLDI 2021. https://dl.acm.org/doi/10.1145/3453483.3454039
- Martin Odersky et al. A Type System for Capabilities. Draft, 2022 onwards. https://github.com/lampepfl/dotty/tree/main/docs/_docs/reference/experimental/cc.md
- Andrej Bauer, Matija Pretnar. Programming with Algebraic Effects and Handlers. JLAMP 2015. https://www.sciencedirect.com/science/article/pii/S2352220815000565
- Apple. Swift typed throws. https://docs.swift.org/swift-book/documentation/the-swift-programming-language/errorhandling/
- Unison Computing. Abilities and ability handlers. https://www.unison-lang.org/docs/fundamentals/abilities/
- Effekt language. https://effekt-lang.org/
- Koka language. https://koka-lang.github.io/
Copyright
This document is placed in the public domain.