WEP: Closure Implementation Internals
How the closure design in Closure Implementation is realized: dispatch, the fn / fn mut split, and the Wasm GC representation.
Context
A closure literal lowers to a functor struct with a $call method, but a parameter that receives it is typed fn(i32) -> i32:
fn apply(f: fn(i32) -> i32, x: i32) -> i32 {
return f(x);
}
fn run() {
let result = apply(|x| x * 2, 5);
}
Some form of dispatch has to reconcile the two.
Decision
Monomorphize where the type is known, canonicalize where it is erased
A closure whose concrete type is known at the use site — parameter position, unifiable return, generic-bound parameter — monomorphizes onto that functor. Dispatch is static and the body is inlinable.
A closure whose use erases its type — struct field, mixed-branch return, container element — is wrapped in a canonical (env, funcref) struct instead.
Escape analysis in the lower phase decides this per local: a local appearing only as a call's callee keeps the specialised form; every other position forces canonicalisation. Being captured is one of those positions, since the value is stored in another closure's environment. A closure that a nested closure calls is canonical for that reason, and the call goes through the funcref. The choice is invisible at the source level, since both satisfy the same fn(T) -> U.
Boxed trait objects (dyn Fn) were rejected. Wado has GC and needs no Box, and the canonical struct already fills that role without user-visible syntax.
fn / fn mut is a type-system distinction only
There are no Fn / FnMut traits. ResolvedType::Function carries an is_mut flag, check_assignable enforces fn <: fn mut structurally, and a <F: fn(...)> bound binds F directly to the bound's function type rather than to a trait.
Wasm has no shared-versus-exclusive reference distinction, so both compile to the same call_ref. What the split buys is a check at the call site: a fn mut callee requires the binding holding it to be mut.
A closure is fn mut when its body writes a captured mut binding: it assigns the binding or a place in it, takes &mut of it, or calls a &mut self method on it. The writes are read from the syntax before the body is typed, so every written binding is boxed before the first read of it is lowered. Whether a borrow or a method call writes the binding is a typed question, answered by the walk: through a reference it writes the referent instead. Capturing a mut binding it only reads leaves it fn. The body is the whole body, nested closures included, so a write reaching an outer binding from any depth counts. What the closure binds itself shadows, whether a parameter, a let or a pattern, and a write to one leaves the outer binding of that name alone.
Captures are implicit
Captures are auto-by-reference and are not part of the closure type. They live in the environment struct, invisible to the type system, so no captures[...] clause on function types is needed. Mutability lives at the referenced cell, not in the env field's type.
A closure that escapes outlives the bindings it captured, so a boxing pass promotes those bindings to the heap. For a non-escaping closure the environment may be skipped entirely by inlining the call and reading the bindings as locals — an optimization, not a semantic guarantee, and one that has to be expressed as call rewriting because Wasm GC has no stack-slot references.
Retention of non-closure reference parameters stays a separate mechanism (see Value Semantics and Reference Retention).
A capture is handed inward one frame at a time
Environments are flat, not chained. A closure's environment holds the bindings reached from its body, and a nested closure's body is part of that: a frame carries what the frame inside it asked for and did not own, as well as what it names itself. Capture is transitive to any depth that way, one frame at a time, and a frame between two others carries a binding it never mentions.
Each capture records where the frame building the closure reads the value from: its own local, or its own environment slot. The second case is what a nested closure needs, and resolving it makes that frame capture the binding in turn.
A binding is boxed by the frame that owns it, whatever depth the write comes from. The frames in between pass the box along, so they all see the one cell. The owning frame binds a &mut proxy for that box in a local slot annotate reserves, and reify's own allocation has to land on it.
A binding the closure only reads can take a & proxy too. Lowering binds it, not annotate. It does so only when the binding's reference is a box and the owning frame may write the binding while the closure lives. That covers a write after the closure is built, a write in a loop that also builds the closure, and a borrow of the binding anywhere. In every other case the environment holds the value itself. Nothing can change the binding then, so the value reads the same as the reference would, and the binding stays unboxed. A binding whose & is its own GC handle never needs a proxy.
Which slot holds what is decided once. The annotate walk settles a closure's environment and records it. Reify opens the frame with those slots already in place, so its own walk reads them rather than deciding again, and a binding that reaches no slot means the two walks disagree, which the frame reports on the spot. How each binding is reached stays that frame's own question: a local of the enclosing frame, or a slot of its environment, answered against its own parent. No capture index crosses between the walks.
Wasm GC representation
A closure is an environment struct paired with a funcref. Flat closure plus trampoline, defunctionalization, and CM resource handles were all rejected — respectively for not sharing mutable state, interpreter dispatch overhead, and explicit drop plus indirection.
Two structs, on two different keys:
$Closure_N— one per closure literal,(env, func), the specialised form.CanonicalClosure_K— one per full Wasm signature, the type-erased form.
The canonical struct takes one of two shapes, chosen per (arity, return type) by whether any closure of that shape is inspected — the struct's own identity is keyed on the full Wasm signature:
;; Slim (default — call-only)
(type $CanonicalClosure_K (struct
(field $env (ref null struct))
(field $func (ref $canonical_fn_K))))
;; Inspectable
(type $canonical_inspectable_base (struct
(field $env (ref null struct))
(field $inspect (ref $canonical_callback_fn))))
(type $CanonicalClosure_K (sub $canonical_inspectable_base (struct
(field $env (ref null struct))
(field $inspect (ref $canonical_callback_fn))
(field $func (ref $canonical_fn_K)))))
The subtype keeps the base's prefix and adds func last. $canonical_callback_fn is uniform across signatures; $canonical_fn_K is typed per K.
$canonical_inspectable_base is one type for the whole program — its two fields are signature-independent — so every inspectable closure casts to it whatever its shape. The ^Inspect dispatch stubs are not shared: a stub is named after the type it dispatches for, so fn(i32) -> i32 and fn(String) -> i32 get their own even though both cast to that one base.
A stub is synthesized as a bodyless FunctionKind::FnCanonicalDispatch and WIR build supplies its body, a call_ref through the vtable slot. Bodyless functions are skipped by the inliner and the other body walkers, so the placeholder costs nothing during optimization.
Per-literal wrappers cast the canonical env back to $Closure_N and forward to $call or ^Inspect. The specialised path never touches the vtable: lowering rewrites an Inspect call on a known-local closure receiver into a direct call, and DCE removes the impls when nothing calls them.
A closure crosses the Component Model boundary only as a key
An exported signature may not carry a closure type in any position, including one buried in a container, a named struct's field, or a variant payload. An imported one carries it only as a whole parameter, a callback taking scalars and handles and returning nothing.
The closure stays in a guest-side registry and a u32 key crosses instead, with a synthesized export as the trampoline. See Web § Callbacks.
Consequences
- A function taking
fn(...)becomes generic, so code size grows with the number of distinct closures reaching it, in exchange for static dispatch and inlining. - A program that never inspects a closure emits the slim shape and pays nothing for the vtable.
- A program that inspects closures of some
(arity, ret)pays two refs on every canonical value of that shape, plus wrappers and source strings for the affected literals only. - Effects ride the closure type's
withclause, so a closure body is checked against its own effect set rather than the enclosing function's.
Open
- [ ] Value-copy analysis does not model an environment read. The escape, liveness and
carrieswalks in the lower phase have no case for a capture read, so a value flowing out of one frame's environment into a nested closure's is invisible to them. No program is known where that changes a decision, since the read is synthesized after those walks run. - [ ] Return
Iterator<Item = ...>from adapters. They return named structs (IterMap<Self, U>,IterFilter<Self>); the anonymous form needs the elaborator to elaborate a trait-object-style return type. - [ ] Effect-polymorphic iterator methods (
<effect E>). Closure literals already inherit caller effects, so this is convenience rather than a correctness fix. - [ ]
Fn/FnMutas user-namable traits, for user-defined callable types. Closure literals never need them. - [ ] Surface the dispatch choice in the LSP as an inline hint on closure-typed expressions. Purely additive.
See Also
- Closure Implementation — user-visible language spec.
- Struct and Trait System
- Effect System Design
- Inspect (Debug Output)
- WebAssembly GC Proposal
