Skip to main content

MEP 15. Effects

FieldValue
MEP15
TitleEffects
AuthorMochi core
StatusDraft
TypeStandards Track
Created2026-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 print apart from fetch.
  • "Is it safe to memoize this?" Needs time and random distinguished from io.
  • "Is this safe to run at compile time?" Needs the absence of io and fs and net, plus the absence of meta (eval).
  • "Did this hot loop accidentally pick up an io effect because someone added a print for 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

ItemStatus
Pure bool on FuncType (single bit)closed (legacy)
isPureFunction body inferenceclosed
T044 in where and having predicatesclosed
Compile-time constant folding gated on Pureclosed
MEP-16 N5 narrowing drop on non-pure callclosed
Effects EffectSet field on FuncTypeclosed (Stage 1)
Closed label set: io, fs, net, time, metaclosed (Stage 1)
Builtin effect annotationsclosed (Stage 1)
Effect inference (union of callees, plus statement-level effects)closed (Stage 2a)
Pure() method as compatibility shimclosed (Stage 1)
Effect propagation through higher-order genericsclosed (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 methodsclosed (Stage 3b)
User-defined effect labelsby 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:

LabelMeaningExamples
ioWrites to stdout or reads from stdinprint, json, input
fsReads or writes the local file systemload, save
netNetwork I/Ofetch
timeReads the wall clock or other non-deterministic clocknow
metaDynamic compilation or evaluationeval

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:

  • state for "mutates through a captured var". Mutability is tracked by MEP-10 A3 today through root-binding analysis. Re-introducing it as an effect would force every function that uses a var counter to acquire a label, which inflates annotations without enabling new enforcement. Revisit when mut becomes worth a separate axis.
  • throw for "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:

  1. The effect set of every callee invoked in the body (transitively via the callee's own inferred or declared set).
  2. The effect set induced by every statement-level effect performed directly: fetch contributes net, load and save contribute fs, an update statement contributes whatever effects MEP-14 ascribes to it (today, none beyond state which is not tracked).
  3. 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:

StatementEffects
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:

  1. where and having predicates (already T044).
  2. (Future, see Open Questions:) compile-time-evaluable expressions, type-level computation arguments, struct field defaults, and const declarations 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:

  1. The pre-pass that registers function signatures (check.go final pre-pass) is extended to produce an EffectSet per declared function. Initially empty for forward-declared functions, refined when the body is type-checked.
  2. The body walker (already inside isPureFunction, generalized to inferFunctionEffects) walks the AST collecting:
    • Callee effects, looked up from FuncType.Effects.
    • Statement-level effects from s.Fetch, s.Load, s.Save, future s.Throw.
    • Closure-construction sites (no contribution; only call sites count).
  3. The collected set is stored on the function's FuncType.Effects in the surrounding env.
  4. 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 %s is not allowed in %s predicate". Now reads the effect set instead of the binary flag; help text mentions which labels are present.
  • T065 (new): "declared effect set ! %s does 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; const declarations 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:

  1. Stage 1 (shipped, PR #21443): replace the bit. Introduce EffectSet, add Effects EffectSet to FuncType, tag every builtin with its label set, keep the Pure() method as a compatibility shim, regenerate goldens. No surface syntax. T044 keeps firing for non-empty effect sets in where/having. MEP-16 N5 keeps firing for any non-empty effect set.

  2. Stage 2a (shipped): real body-walked inference. inferFunctionEffects walks 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. The legacyEffectsFromPure bridge is removed.

  3. Stage 2b (shipped): surface annotation. Add the ! syntax to FunStmt and FunExpr in the parser. The annotation is parsed as a list of label idents, then converted to an EffectSet in types/check.go. The declared set is treated as the published upper bound: callers see the declared value on FuncType.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.

  4. 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`.

  5. Stage 3b (shipped): FunExpr annotation diagnostics. Anonymous function expressions (fun(x) ! io => print(x)) now run the same T064 / T065 validation as named functions. parseDeclaredEffects is split so both FunStmt and FunExpr share the label parser. A new inferFunExprEffects walks both BlockBody and ExprBody symmetrically. The closure's FuncType.Effects is 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.

  6. Stage 3c (shipped): reserve T066. The pure-position diagnostic template is registered in types/errors.go, the errEffectInPurePosition(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 when const declarations 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.

  7. Stage 3d (shipped): HOF callback effect propagation. A new callableEffects(e, env) helper in types/effects_infer.go detects 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. Both primaryEffects (direct calls f(args)) and postfixEffects (postfix / method calls) union in callableEffects for each argument. This means apply(fun(x:int):int{ print(x); x }, 5) and apply(do_print, 5) are both correctly typed as !io at the call site, regardless of apply'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.

  8. 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.go FuncType.Effects: replaces Pure bool; Pure() method returns Effects.IsEmpty().
  • types/effects.go (new): EffectSet type (sorted bitset over the closed label index), Union, IsSubset, IsEmpty, String, ParseLabel.
  • types/check.go builtin block: each existing Pure: true becomes Effects: EmptySet(); impure builtins acquire labels (print: io, now: time, eval: meta, etc.).
  • types/pure.go rename to types/effects_infer.go: isPureFunction becomes inferFunctionEffects. firstImpureCall becomes firstEffectfulCall, returning the label list at the offending site for better error help text.
  • types/check_expr.go: T044 site at where and having predicates reads Effects.IsEmpty() instead of Pure.
  • types/check.go if-statement narrowing loop (MEP-16 N5 hook): reads Effects.IsEmpty().
  • runtime/vm/vm.go constant-folding gate: reads Effects.IsEmpty().
  • parser/ast.go FunStmt.Effects: []string populated by stage 2 parser. Empty at stage 1.
  • types/errors.go: T044 help text gains label list; T065 added; T066 reserved with errEffectInPurePosition constructor.
  • types/effects_infer.go callableEffects: detects bare-callable arguments (FunExpr literal or identifier of function type) and returns the effects incurred by calling them; wired into primaryEffects and postfixEffects arg loops (Stage 3d).

Open questions

  • const declarations and compile-time evaluation. A natural pure position; the design above reserves T066 for it. Land when const lands.
  • Effect inference for struct method defaults. Default expressions in struct field declarations must be pure for the same reason const must 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.
  • time versus clock versus now. The label time covers any wall-clock or monotonic-clock read. A future monotonic-only API would still carry time because 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

This document is placed in the public domain.