Skip to main content

MEP 15. Effects, Mutability, and Purity

FieldValue
MEP15
TitleEffects, Mutability, and Purity
AuthorMochi core
StatusDraft
TypeStandards Track
Created2026-05-08

Abstract

Mochi is not effect-typed. There is no row of effects on function arrows. What we do have is a Pure flag on FuncType, a let versus var distinction on bindings, and a small set of side-effecting builtins. This MEP documents the current state, proposes a tightening of mutability checks under index and field operations, and asks whether to keep or remove the unenforced Pure flag.

Motivation

Today let is half-immutable: top-level reassignment is rejected, but let xs = [1]; xs[0] = 9 is accepted. The half-enforcement is a soundness gap and a footgun. Either we honour the let contract everywhere or we drop it.

Specification

What "effect" means here

Mochi is not effect-typed. There is no row of effects on function arrows. The pieces we do have:

  • Pure flag on FuncType. Set on builtins that are deterministic pure functions and on user functions whose bodies are inferred pure. Not enforced at call sites today.
  • let versus var mutability annotation on bindings. Enforced for top-level reassignment, partially enforced for nested mutation (MEP 10 A3).
  • I/O statements: print, json, fetch, load, save. These are not flagged by the type system; the programmer knows they are effectful from the function name.

Mutability today

The state of mutability checks:

  • let x = e. Reassignment of x raises T024.
  • var x = e. Reassignment of x is allowed.
  • let xs = [1, 2]; xs[0] = 9. Accepted today. Should be rejected.
  • let p = {x: 1, y: 2}; p.x = 9 for a struct p. Accepted today. Should be rejected.
  • let mp = {"a": 1}; mp["a"] = 9. Accepted today. Should be rejected because mp is let.

The fix described in MEP 10 A3 is to inspect the mutability of the root binding of an AssignStmt, walking through Index and Field ops to find the root.

Closures complicate this. A closure that captures a var binding can mutate it. A closure that captures a let binding cannot reassign it but today can mutate through indices and fields in the same way as above. After A3, closures should follow the same rule.

Aliasing

There is no aliasing analysis. Two bindings can refer to the same underlying value:

let xs = [1, 2, 3]
var ys = xs
ys[0] = 9 # observable through xs?

Runtime semantics for lists and maps are reference-based, so the mutation is observable. After A3, the example above becomes a type error at the ys[0] = 9 line because the root binding of ys is var, but the underlying list still aliases xs. We accept that.

If we wanted unique ownership, we would need a substructural discipline. It is out of scope.

Pure flag

The Pure: true field on builtins is currently a label without enforcement. Two design directions:

A. Drop the flag. It is unused and misleads readers.

B. Enforce the flag in specific contexts. Candidates:

  • Type alias arguments. type Adult = filter(People, isAdult) (does not exist today, but if we add type-level computation, the function would have to be pure).
  • Query predicates. where and having should be pure to give the optimiser room to reorder.
  • Struct field defaults (does not exist today).

Direction B is more honest. The minimum viable enforcement: require where and having predicates to call only pure functions. This is a conservative rule and easy to test.

Side-effecting builtins

The builtins that have side effects:

  • print. Writes to stdout.
  • json. Writes pretty JSON to stdout.
  • now. Reads the clock (not pure even though it is read-only).
  • fetch. Network I/O.
  • load. File I/O (or stdin when path is omitted).
  • save. File I/O (or stdout when path is omitted).
  • update. Mutates a stored entity.

If we adopt direction B above, these stay non-pure and any call to them is rejected in the pure positions.

Effect on equality and reordering

The current semantics are sequential. There is no implicit parallelism. Reordering of function calls is not safe.

Once query optimisation matters (it does for the SQL-like surface), we will want to:

  • Know that a where predicate is pure so we can push it through joins.
  • Know that an aggregate function is pure so the optimiser can parallelise.

This is the practical motivation for direction B. We do not need a general effect system, just a "pure" flag we trust.

Resource management

There is no try or RAII-like construct. Resources opened by load are released when the program exits. We are happy with that. If we add long-lived processes, we revisit.

Mutability of struct fields

A struct declared type Point { x: int, y: int } has fields that are implicitly mutable through a var binding and immutable through a let binding. There is no per-field let or var keyword inside a struct. We do not propose adding one; it adds complexity for little practical benefit.

Fixtures

Existing:

  • assign_to_let (error).
  • var_mutable (valid).
  • map_assign_immutable (error, partial).
  • assign_undeclared_var (error).

New:

  • mutate_let_index_rejected (error, after A3).
  • mutate_let_field_rejected (error, after A3).
  • mutate_let_map_key_rejected (error, after A3 and applies to map).
  • mutate_var_index_ok (valid).
  • mutate_var_field_ok (valid).
  • mutate_var_map_key_ok (valid).
  • closure_mutates_let_via_index (error after A3, valid today).
  • closure_mutates_var_ok (valid).
  • pure_in_where_ok (valid, after pure enforcement).
  • impure_in_where_rejected (error, after pure enforcement).

Rationale

Tightening mutability under index and field assignments matches what users expect from let. Without it, let is a partial promise, and partial promises are worse than no promise.

Direction B for the pure flag pays for itself the moment we want to push predicates through joins. Direction A is cheaper but throws away an existing investment. We recommend B.

Backwards Compatibility

Tightening A3 is a breaking change for programs that mutate let bindings through indices or fields. The fixture set captures the current behaviour and the migration path is to change let to var at the binding site.

Pure enforcement is a breaking change for programs that call impure functions from where or having. The migration is to move the impure work outside the predicate.

Reference Implementation

  • types/check.go — current mutability check on AssignStmt.
  • types/check.go:393-562Pure flag on builtin registrations.
  • types/env.go:179, 187 — env shadowing without warning.

Open Questions

  • Direction A or B for the pure flag. Recommendation is B. Need consensus.
  • Aliasing analysis. Out of scope, but worth flagging when we revisit.
  • Resource scoping. No try/RAII today; revisit if long-lived processes land.

References

  • Benjamin C. Pierce, Types and Programming Languages, chapter 13.
  • Eric Holk et al., "Mochi" — internal design notes on streams and intents.

This document is placed in the public domain.