Written Function Types Lower to Move-Only
Status — draft (2026-09-03). Split from RFC-0163 (
2-accepted) so the urgent, design-agnostic part of it ships in v0.13.0 while the rest is rescheduled to v0.17.0 to co-design with RFC-0162 (Copy-model design space). This RFC is deliberately small: one rule, one deletion, no new keyword, no new type-model state. It re-litigates nothing — the design space is RFC-0163's.
Status — under review (2026-09-03). Split from RFC-0163 the same day; tracking metel-core#946, milestoned v0.13.0. Content is RFC-0163's own alternative D, stated minimally — nothing here is new design.
Status — accepted (2026-09-03). adversarial review folded in (F1-F10); frontend delta is one deleted nested_fun_axes_match exception plus specifying existing behaviour
Status — integrated (2026-09-03).
Status — implemented (2026-09-03).
Summary
Every syntactically written |T| -> U type node — anywhere a type is
written, and every function-type node nested inside another — has concrete
Move use-multiplicity (RFC-0134 §4). A function value that RFC-0134 proved
Copy (a named function, a capture-free closure, a closure whose captures are
all Copy) is accepted into a written function-type slot by moving — this is
RFC-0152's existing first-order Copy → Move direction, now stated. Its
Copy-ness is not carried by the written type and is not re-derived downstream.
Below the first function level the use axis matches exactly, as once /
var already do.
The change against the frontend is precise: specify the TypeExpr::Fun →
Move lowering and the first-order Copy → Move acceptance (today's
unexplained "conservative placeholder refined during construction"); delete
the one nested_fun_axes_match exception that lets a written nested function
type reconcile with an inferred Copy one; leave the symmetric
generic-scheme acceptance as documented debt for RFC-0163 to remove. It adds no
copy qualifier, no Erased state, and reserves no keyword — those are
RFC-0163, rescheduled to v0.17.0.
Motivation
RFC-0134 gives every function value a use-multiplicity axis: Copy when its
captures are Copy, otherwise move-only. Written function types can spell
once and var but cannot spell this third axis, so |T| -> U has no
defined meaning on it. The frontend currently papers over the gap: typeinference
lowers a written function type to a move placeholder and then carries a
Copy-to-Move special case in unify_seq, in nested matching, and in generic
construction that lets a concrete Copy value slip through. That behavior is
invisible in the language, is a special case one refactor away from becoming a
general nested-widening rule (which would violate RFC-0152's "exact below the
first nesting"), and forces the compiler to decide an unstated language question.
The v0.13.0 closure cluster (RFC-0050 / RFC-0134 / RFC-0152 / RFC-0153 /
RFC-0157) landed and exposed this gap. It should not ship unpatched. But the
full fix — a third Type::Fun state, a copy qualifier, provenance tracking —
is large, still has open mechanism questions (RFC-0163 §J1–J5), and is coupled to
a decision Metel has not made: whether regular values keep implicit Copy at all
(RFC-0162 Axis A). This RFC ships the part that is safe under every such
decision.
Proposal
1. Lowering
Lowering is defined recursively over the written type expression, not the
resolved Type:
- Every syntactically written
TypeExpr::Funnode lowers touse_multiplicity = Move— its outer node and every function-type node nested inside it (a parameter, return, element, or field of another function type). This is positional and total: it applies in a parameter type, alet/varannotation, an ascription, a declared function / method return, a struct or enum field type, a written aggregate element type ((|i64| -> i64,)), a generic type argument (W<|i64| -> i64>), an alias body, an aspect method signature, and a?slot under a declared return. - A bare type parameter (
F) is opaque — it is not aTypeExpr::Funnode, so nothing lowers;Fbinds to the argument's resolved type verbatim. - A transparent alias and a resolved associated-type projection carry their
already-lowered axis through expansion —
type Cb := |i64| -> i64makesCba written function type wherever it is used;type W<T> := (T,); f: W<|i64| -> i64>lowers the tuple element. - An inferred function value keeps RFC-0134's capture-derived
use_multiplicity. Lowering is a property of the declared slot type, never a retyping of the value. A closure literal constructed in a written context still has the capability its captures give it; theCopy → Movestep (below) happens at the boundary, when the value flows into the slot — the value's own type is unchanged.
2. Compatibility
There is one compatibility rule, directional and first-order:
A Copy function value is accepted into a written (Move) function-type slot
by moving. map(add_one) type-checks — add_one (concrete Copy) moves into
f: |T| -> U. Inside the callee f is move-only: callable, movable, not
duplicable. If map returns f (-> |T| -> U), the caller receives a move-only
value; the callback's Copy-ness is dropped at the parameter boundary and is
not re-derived downstream — construction must not inspect a value's origin to
restore Copy behind a written function type.
This is RFC-0152's existing first-order Copy → Move direction, made explicit and
sanctioned rather than left as an unexplained guess. Below the first function
level the use axis matches exactly, exactly as RFC-0152 already requires for
once / var. RFC-0155's higher-order variance question is untouched.
The frontend change
Concretely, against metel-frontend/src/typeinference/mod.rs:
- Keep, and specify:
TypeExpr::Funlowering to theMoveplaceholder (InferType::Fun(.., Move, ..)); and the first-orderCopy → Moveacceptance —unify_seq's(Move, Copy)normalization andunify's first-orderuse_okdirection. These stop being "a conservative placeholder refined during construction" and become the stated rule above. - Delete: the
(UseMultiplicity::Move, UseMultiplicity::Copy)exception innested_fun_axes_match. That is the one line permitting a written (Move) nested function type to reconcile with an inferredCopyone below the first nesting — precisely the accidental nested latitude RFC-0152 forbids for the other two axes. After this, nested use-axis matching isau == buonly, with the first-order(Move, Copy)acceptance retained by a depth parameter (nested_fun_axes_match_at, depth0for a direct param / return / element of the first-order match, exact at depth≥ 1). Structural recursion through a tuple / record / array does not count as a function level — aCopycallback inside a tuple that is a first-order argument is still first-order. - Restamp at the declared-type boundary: the move checker reads a binding's
type from its constructed RHS expression (
TypedLetDecl::value.ty()), so alet/var(andfor-init) binding annotated with a function type must take the written (Move) type, not the value's own — otherwiselet f: |T| -> U := named_fn; let a := f; let b := f;would keepfCopy. Construction now runs amaybe_fn_move_coercestep in the same chain asmaybe_read_copy/maybe_singleton_coerce/maybe_dyn_coerce, and inference'sconstrain_with_read_copyreturns the declared function type for the same reason. Parameters and declared returns already carried the written type, so they needed no change. - Note as debt, do not touch for v0.13.0: the symmetric
use_okin generic-scheme construction (use1 == use2 || use1 == Copy || use2 == Copy, guarded by!generic_axes). It is monomorphization-deferred scaffolding — the concrete direction is enforced at each call site. RFC-0163's "one resolved directional relation" removes it at v0.17.0; RFC-0166 leaves it exactly as is and states that it does. - Unchanged: the aspect check —
InferType::FunsatisfiesCopyiff itsuse_multiplicityisCopy, so a written function type (Move) does not, and a closure literal with all-Copycaptures does. RFC-0166 does not change what a function value satisfies; it only fixes what a written type lowers to.
The compatibility matrix these produce:
| Site | Copy value → written |T| -> U | inferred Copy nested under written |T| -> U | Move value → written |T| -> U |
|---|---|---|---|
direct argument / let / return (first-order) | accepted, becomes Move | — | accepted |
| nested (param/return/element of a function type) | rejected (was accepted via the deleted exception) | rejected | rejected unless au == bu |
| generic scheme checking with type vars | permissive (debt; concrete direction enforced at the call site) | permissive (debt) | permissive (debt) |
Explicitly out of scope (RFC-0163, v0.17.0)
copy |T| -> U— the positive assertion "this callable may be duplicated." Not added here; a body that needs to duplicate a callback cannot express it through a written function type until v0.17.0 (see Migration).- The
Erasedthird state — "capability unknown" as distinct from "proven move-only." Under this RFC a written function type is simplyMove. - The
copykeyword reservation — deliberately not made here. This is a choice with a cost:copyis a valid identifier today in alet/varbinding, a parameter, a generic parameter, a function / method / type-alias name, a struct field, an enum variant, a pattern binding, and animport … asalias — and the repository already has at least onelet copy. RFC-0163 at v0.17.0 reservescopyand runs the full identifier-position sweep; that is a keyword-breaking release for thecopyidentifier, not an additive change. Native / raw-dotted paths keep their own keyword-permissive grammar and are out of that sweep. Accepted as a bounded, deferred break to keep this RFC to one semantic rule. - Per-node
writtenprovenance, the coercion table, the join expected-context rule, generic-rigidity scoping — RFC-0163 §J1–J5, resolved in the v0.17.0 window. RFC-0166 needs none of it: with nocopyand noErased, every written function-type node lowers to the same value (Move), so there is nothing per-node to disambiguate.
Migration
Hard switch, one sweep (Metel has no public users). Runs after the RFC-0050 / RFC-0153 corpus sweeps.
-
Bodies that duplicated a bare-typed callback. A signature
f: |T| -> Uwhose body doeslet a := f; let b := f;(or otherwise usesfby value more than once) compiled today because the frontend normalised the written type to concreteCopy. Under this RFCfis move-only and the second use is a use-after-move.This is a checked-mode migration. Non-copyability of a written function type is a move-checker property (RFC-0166 does not add move tracking to plain type-checking); the default evaluator still deep-clones by-value uses, so an offending body keeps running until
--move-checkis on. The sweep therefore runs the move checker explicitly over the whole corpus with--move-check, and "no user generic function body is recorded as move-unchecked" is a release criterion (the move checker skips a generic body when it cannot rebuild its scheme; those bodies must be checkable or restructured, not left silent).There is no
copyspelling to fix a flagged body with until RFC-0163 at v0.17.0. The remedies available in v0.13.0:- restructure so
fis used by value once — call it (amanyreadingcall is a shared borrow, not a consume, so repeated calls are already fine; the error is a repeated by-value move), then move / store / return it; - take a generic
<F>parameter — pass-through / storage only;Fcannot be called without RFC-0161's callable bound.
A function value is not
Clone(a closure satisfies noCloneaspect — RFC-0134), so.clone()is not a remedy. Expected to be rare — signatures that only callf(any number of times), or store / move / return it once, need no change; located by the--move-checksweep. - restructure so
Forward compatibility
Move→Erasedis a refinement — as a proof obligation, not an assertion. RFC-0163's own F4 / H8 analysis establishes thatMoveandErasedimpose identical source-observable use behavior in a language with no exact-movespelling (call, move, never copy, never accepted intocopy); they differ only in the representation invariant and a futuremovequalifier, neither source-visible. So refinement should be source-transparent — but RFC-0163 also makesErased ≠ Movedeliberate, preserves generic types verbatim, and matches nested exactly. That combination is demonstrated, not assumed: RFC-0166's3-integratedfixture set is the refinement regression corpus, and passing it unchanged under RFC-0163's model (generic pass-through, aliases, associated projections, higher-order callbacks, joins, fields, returns) is an acceptance criterion for RFC-0163's own3-integrated.- Not reserving
copyis a deferred break, not an additive change. RFC-0163 at v0.17.0 reservescopyand does the full identifier-position sweep; a v0.13.x–v0.16.x program that usedcopyas an identifier needs a rename then. Bounded (the sweep is mechanical, no public users) and deliberate — the cost of keeping this RFC to one semantic rule. Reserving now would be strictly cheaper in isolation; it is deferred because thecopymeaning and itsonce/varcontextual-family question belong with RFC-0163. - If RFC-0162 adopts P2 (no implicit
Copyat all), both this rule and RFC-0163 are moot — there is no implicit copyability to forget. This RFC's rule is still harmless in that world (a bare function type is move-only, which is what P2 makes everything).
Relationship to existing RFCs
- RFC-0163 (Function-Type Use-Multiplicity Surface,
2-accepted, rescheduled v0.17.0, #936) — this RFC is its conservative v0.13.0 slice. RFC-0163 keeps its full design (theErasedstate,copyqualifier, per-nodewrittenprovenance, coercion table); this RFC lowers every written function-type node toMove, which RFC-0163 refines toErasedrather than restates. - RFC-0134 (Closure Call Capability,
4-implemented) — ownsuse_multiplicityonType::Fun. This RFC only fixes what a written function type lowers to; capture-derived capability for closure values, and what a function value satisfies as an aspect (Copyiffuse_multiplicity == Copy, neverClone), are unchanged. - RFC-0152 (Function-Type Multiplicity Widening,
4-implemented) — untouched. This RFC's first-orderCopy → Moveacceptance is RFC-0152's existing rule, now stated; the use axis is matched exactly below the first nesting, exactly as RFC-0152 already requires foronce/var(the deletednested_fun_axes_matchexception was the one place that was not true). - RFC-0155 (Higher-Order Function-Type Multiplicity Variance, unscheduled) —
untouched and unweakened; there is no erasure or
copy↔bare relation for it to have to accommodate. - RFC-0162 (Copy and Clone Model — Regular-Value Design Space,
1-under-review, v0.17.0) — the coupled decision. This RFC is chosen to be sound under every RFC-0162 Axis-A position (P0 / P1 / P4) and harmless under P2. RFC-0163's full surface waits for that decision. - RFC-0161 (Callable Object Contract,
1-under-review) — a generic<F>parameter (a Migration workaround here) cannot be called without RFC-0161's callable bound.
Lifecycle
3-integratedadds aspec.functions.first-class-functionsLegality Rule block for: written-function-type lowering (recursive, per node,Move); the first-orderCopy → Moveacceptance; nested exact matching;Copy-ness not re-derived. Fixtures — aCopynamed function intomap(f: |T| -> U)(accepted, comes back move-only when returned); a move-only closure into the samemap; a use-after-move on a duplicated bare callback (--move-check); an inferredCopyclosure rejected against a nested written function slot (the deleted exception); an alias / generic-argument / associated-projection written function type erasing at a first-order boundary and matching exactly nested. This set is also RFC-0163'sMove → Erasedrefinement regression corpus.4-implemented: state theTypeExpr::Fun → Movelowering and the first-orderCopy → Moveacceptance; delete the(Move, Copy)exception innested_fun_axes_match; leave the symmetric generic-schemeuse_okas documented debt; the--move-checkcorpus sweep with its "no move-unchecked user generic body" release criterion; the.clone()advice is not offered (function values are notClone).
Decision
Outcome: (pending — 1-under-review, tracking metel-core#946. Split from
RFC-0163 on 2026-09-03 to decouple the urgent frontend fix from the v0.17.0
Copy-model work. The rule is RFC-0163's own conservative alternative D, stated
minimally against the actual typeinference mechanisms; no design space is
reopened. An adversarial review (2026-09-03) added the recursive lowering scope,
the precise keep/delete/debt breakdown of the frontend change, the checked-mode
migration framing, and the Move → Erased proof obligation.)
Target: v0.13.0 — removes the typeinference Copy-to-Move guess exposed by
the closure cluster. Nothing here blocks the rest of v0.13.0.