Generic Functions as First-Class Values
Status — under review (2026-08-27). Committed to a specific implementation, already underway (metel-core#844), against v0.13.0 -- real engagement, not a schedule bump.
Status — accepted (2026-08-27). Design proven by implementation: all four open questions resolved (see Implementation Notes), full acceptance-criteria coverage (bare reference + higher-order argument, top-level + nested) shipped with passing regression fixtures in metel-core#844.
Status — integrated (2026-08-27). Merged into spec.functions.first-class-functions.legality-2, checked against the rest of the currently-integrated spec; no soundness gap found.
Status — implemented (2026-08-27). metel-core#844 merged to develop; 869+ workspace tests green, clippy/fmt clean. Out-of-scope boundaries (rank-2, standalone turbofish value form) now documented in the spec and covered by regression fixtures (metel-core#845 filed separately for an unrelated pre-existing gap found while adding one of them).
Summary
functions.md's first-class-functions claim ("named functions and closures are
values of their function type and may be bound, passed as arguments, and returned
as results") has no carve-out for generic functions, but a <T>-declared named
function today has no value form at all — not a bare reference, not a higher-order
argument, in any position, fully instantiated or not (metel-core#736). This RFC
proposes closing that gap by extending the mechanism that already exists for
closure let-polymorphism — deferred, per-use-site instantiation from a stored
TypeScheme, currently gated on the right-hand side being a syntactic closure
literal — to also recognize a bare reference to an already-declared generic
function.
Motivation
fun identity<T>(x: T) -> T { return x; }
fun main() -> i64 {
let alias := identity; // T0003 undefined name -- `identity` plainly exists
return alias(3);
}
A non-generic function is genuinely first-class today (let f = add; f(1, 2)
works). A generic function is call-only: identity(3) and
identity::<i64>(3) both work, but naming identity in any other position —
a bare reference, a higher-order argument (apply(identity, 3)), or an
explicit-instantiation-without-calling form (identity::<i64>, which is also
a parse error today — the turbofish production is fused to the following call
parens) — fails. functions.md's claim is unqualified; the implementation
doesn't match it.
This is a narrower, more specific gap than the historical let-polymorphism
epic (#10/#290/#295, closed): that work covered a closure inferred to be
generic through how its body is used (let id = fun(x) { x };), and already
shipped — see spec.functions.closures and the GenericClosure construction
path below. This RFC is about a function whose generic signature is already
fully known from its own <T> declaration, referenced by name rather than
constructed as a literal at the reference site.
Root cause, traced end to end
Contrary to this RFC's own issue (#736), which locates the gap in
construction.rs, inference already succeeds for let alias = identity;:
hoist_fun_decls(inference.rs) binds every top-level function — generic or not — intopoly_envviactx.bind_poly, including a real quantifiedTypeSchemefor a generic function built from its own signature (no body inference needed first, unlike a closure).InferContext::lookupauto-instantiates apoly_envhit with fresh type variables on every call. Soidentityresolves fine insideinfer_expr'sExpr::Identarm, for a bare reference exactly the same way it does for a call's callee position (Expr::Callrecurses into the sameinfer_expron its callee).- What inference does not do is re-generalize
aliasitself: the closure special case (infer_decl'sDecl::Letarm) that re-quantifies an unannotated let-binding's free variables into a freshpoly_envscheme is gated onmatches!(&ld.value, Expr::Closure { .. })— a bareExpr::IdentRHS never reaches it.aliasgets bound as one fixed monomorphic instantiation ofidentity's type via the ordinary path instead.
The actual, currently-observed failure is in the construction pass
(construction.rs), which runs second, over the substitution inference already
solved:
ConstructCtx::newpopulates its own name→Typeenv (ctx.env) fromscheme_env— but only for monomorphic entries (scheme.quantified_vars.is_empty()). A generic function's scheme is deliberately excluded, exactly as theGenericClosureconstruction comment states for the closure case: "the name stays absent fromctx.envso call sites usescheme_envinstantiation inconstruct_call."ConstructCtx::lookup(a plainctx.envscan, noscheme_envfallback) is whatconstruct_expr'sExpr::Identarm calls. Foridentity, this correctly finds nothing — by the design above — and the arm has no fallback toctx.scheme_env, so it falls through toT0003 undefined name. The call-construction path (construct_calland the several sites it shares the pattern with) does checkctx.scheme_envdirectly, which is whyidentity(3)constructs fine: the callee is never routed through the genericExpr::Identarm for a direct call.
So: inference's gap is that alias doesn't get re-generalized (a latent
correctness issue, currently unobserved because construction fails first);
construction's gap is that a bare Expr::Ident referencing a generic
function's scheme_env entry has no handling at all, matching neither the
monomorphic env fast path nor the closure-literal special case. Both need to
be closed for a bare reference to be genuinely, reusably polymorphic rather
than either an error or a single frozen instantiation.
Design
1. Recognize a generic-function reference, not just a closure literal
Both of the closure-let-polymorphism special cases — infer_decl's
Decl::Let arm (inference) and construct_decl's Decl::Let arm
(construction) — are currently gated on:
matches!(&ld.value, Expr::Closure { .. })
Widen the condition to also match Expr::Ident(name, _) where name names an
existing polymorphic scheme (ctx.poly_scheme(name) on the inference side,
ctx.scheme_env.get(name) on the construction side) with a non-empty
quantified_vars. This is a narrow, syntactic RHS-shape check — the same
kind the closure case already uses — not a general expression-position
analysis. It does not need to look inside apply(x) or any other
non-trivial expression; only let name = <bare generic function name>;.
- Inference side: skip the ordinary
infer_expr(&ld.value, ...)monomorphic-instantiation result foralias's own binding, and instead re-bindaliasintopoly_envunder the same schemeidentityalready has (or a renamed copy of it — see below on why a copy, not the same scheme object, is what mutual-recursion-safe code already does elsewhere). This is strictly simpler than the closure case, which has to derive the scheme viageneralize()after solving a freshly-inferred body; here the scheme already exists, fully formed, atidentity's own declaration. - Construction side: produce a construction-side value node carrying the
referenced scheme (see §2), and — mirroring the closure case exactly —
keep
aliasabsent fromctx.envsoalias's own later call and higher-order-argument sites resolve throughctx.scheme_env, the same asidentity's did.
2. A value node for "reference to a scheme," not just "closure body"
TypedExpr::GenericClosure currently carries the closure's own AST
(params, return_type, body) — appropriate for a literal, since the
closure has no separate declared existence to point back to. A named generic
function already has one: it's a top-level (or nested) FunDecl, checked
once, with a stable name and (per METEL-187) potentially a SymbolId.
Two shapes are viable:
- (a) Reuse
GenericClosure, populatingname: Some(identity)(a field it already carries) and treating aNonebody, or a body reference, as "go look this up by name inscheme_env/the function table instead of interpreting an inline body." Minimal new surface, but overloads a node whose other fields (params,return_type,body) don't obviously apply to a named function the same way. - (b) A new
TypedExpr::GenericFunctionRef { name, ty }node, parallel to the existing plainTypedExpr::Identbut explicitly carrying that this name resolves polymorphically throughscheme_envrather than a concrete binding. Keeps the closure-literal and named-function cases textually distinct at the one place (interpreter dispatch) that has to know the difference, at the cost of one more node variant threaded through construction, interpretation, and any exhaustive match overTypedExpr.
(b) is likely the better fit — a named generic function is not a closure, and
forcing it through GenericClosure's shape to satisfy an interpreter that
already knows how to call a named function by name (todays's ordinary path
for identity(3)) adds indirection without buying anything. This RFC does
not commit to (a) vs (b); it's an implementation-time call once the
interpreter-side dispatch (§3) is worked out concretely, not a decision with
externally-visible consequences either way.
3. Runtime representation
The interpreter's existing dispatch for a direct call to a named function
(generic or not) already knows how to find and execute it by name — that
path is untouched by this RFC. What's new is a value — something that can
be bound to a variable, stored in a struct field typed (i64) -> i64, or
passed to a higher-order parameter — that, when later called, dispatches the
same way.
The natural representation is a closure value whose captured environment is
empty and whose "body" is: call the named function identity (dictionary/
generic dispatch resolved the same way a direct call already resolves it,
since — per RFC's own spec.declarations.aspects.static-dispatch-only —
this compiler monomorphises rather than using vtables; a generic-function
value still needs a call-site-driven, not creation-site-driven,
instantiation, exactly like the existing GenericClosure value already
provides for the closure case). No new runtime value kind is needed if the
existing GenericClosure runtime representation (however the interpreter
already executes one — this RFC does not need to re-derive that, only extend
what constructs one) already defers instantiation to each call. If it does
not — if it bakes in a single instantiation at closure-value-creation time —
that would already be a latent bug for the existing closure-let-polymorphism
feature (a closure value reused at two different types), independent of this
RFC, and should be checked as a prerequisite rather than assumed.
4. Higher-order argument position
apply(identity, 3) (the issue's own example) passes identity where
apply's own parameter has a concrete, monomorphic function type
((i64) -> i64 or similar) — inferred per call site as today, from the
argument's expected type. This is rank-1 use: identity itself stays
polymorphic as a name, but any single occurrence of it as an argument value
is instantiated once, at that call, against whatever the parameter position
expects — exactly parallel to how a bare reference's let-binding gets one
instantiation per distinct use of the alias, not one global instantiation
shared across every use. This does not require rank-2 polymorphism (a
function parameter that is itself still-generic inside the callee body) —
apply's own parameter type is concrete before and after this RFC. The
issue's acceptance criteria list this as a required case; it falls out of
§1's mechanism directly, since inferring an argument expression already goes
through the same infer_expr/Expr::Ident path a bare reference does — no
separate mechanism is needed for it.
5. What stays out of scope
- A standalone instantiation-without-calling form (
identity::<i64>as a value, not a call) — currently a parse error (turbofish is fused to a following call's parens). Worth its own, separate grammar change if wanted; not required forfunctions.md's claim, which only promises a function is a value, not that every explicit-instantiation spelling produces one. Tracked here as a known related gap, not part of this RFC's Decision. - Rank-2 polymorphism (a function value whose own parameter position is still quantified inside the callee) — not implied by the issue, not proposed here.
- Nested generic functions (
fun outer() { fun identity<T>(x: T) -> T { ... } identity }as opposed to top-level) — the issue's acceptance criteria explicitly requires this to work identically to the top-level case.hoist_fun_declsalready runs per-block (it's how mutual recursion within one block works today), so the samepoly_envbinding this RFC relies on for the top-level case should already exist at the same scope for a nested declaration; this needs confirming during implementation, not a separate design.
Relationship to existing RFCs
- RFC-0041 (Lambda Syntax for Anonymous Functions) and the historical
let-polymorphism work it built on (#10/#290/#295) — this RFC extends the
same
scheme_env-deferred-instantiation mechanism to a second syntactic shape (a bare name) rather than introducing a new one. - RFC-0134 (Closure Call Capability) — adjacent surface (calling a stored closure value); this RFC does not depend on it and proposes no change to how a call through a first-class value works, only to how a generic named function becomes such a value in the first place.
spec.functions.first-class-functions.legality-2— this RFC narrows that rule's call-only carve-out to the two cases actually left in scope (§5): a bare reference and a higher-order argument are no longer call-only; a rank-2 position and a standalone turbofish-without-call value form still are.
Out of Scope
See "What stays out of scope" above (§5) for the specific deferred surfaces (bare turbofish-without-call, rank-2 polymorphism). This RFC is scoped to: a bare reference, a higher-order-argument use, for both top-level and nested named generic functions — matching metel-core#736's acceptance criteria exactly.
Open Questions
All four resolved during implementation (metel-core#844); see "Implementation Notes" below for how each was actually settled.
GenericClosurereuse vs. a new node (§2) — not resolved here; implementation-time call once interpreter dispatch is worked out.Does the existingGenericClosureruntime value already defer instantiation per call, or bake one in at creation? (§3) — if the latter, that's a pre-existing bug independent of this RFC and should be fixed first; this RFC's design assumes per-call deferral either way, so it isn't blocked on the answer, only on the fix if the answer is "bakes one in."Scheme copy vs. shared reference when re-bindingaliasintopoly_env(§1) — whetheridentity's own scheme object can be shared directly or must be cloned per alias name to keep quantified-variable identity from leaking between them under later renaming/instantiation. Existingbind_polycall sites already cloneTypeSchemes on every bind, suggesting cloning is the established pattern, not a new decision — flagged here for implementation-time confirmation rather than left implicit.Interaction with#735'sClosureBody::Untypedruntime scheme lookup — the issue that found this one suspected that lookup'sNonebranch might be dead code, since nothing upstream currently produces a bare generic-function value for it to receive. Once this RFC's mechanism exists, that branch may become reachable for the first time; needs re-checking against#735when this lands, not assumed resolved by either issue alone.
Implementation Notes (2026-08-27, metel-core#844)
Written after implementation, to record how design questions this RFC left open were actually settled — not a re-derivation of the Design section above, which stands as originally proposed.
- §2 resolved as neither (a) nor (b) as stated, for the bare-reference
case — reused
GenericClosure, but sourced from the referenced function's own declaration rather than an inline literal. A newConstructCtx::fn_table(scope-stacked, hoisted top-level inconstruct_programand per-block inconstruct_block, mirroring the existing local-struct hoist) maps a generic function's name to its ownparams/return_type/body, lettingconstruct_decl'sDecl::Letarm build aGenericClosurenode for a bareExpr::IdentRHS exactly the way it already does for a closure literal RHS. No newTypedExprvariant was needed. - The higher-order-argument case needed neither (a) nor (b) at all.
Tracing the evaluator found every top-level function (generic or not) is
already bound into the runtime environment as a
Value::Callableat program setup (independent of this RFC), andTypedExpr::Ident's evaluator arm is a plain, type-agnosticenv.get(name)that ignores its owntyfield. Soconstruct_expr'sExpr::Identarm, given a concreteexpected_ty(from a monomorphic higher-order parameter, or an explicitly-annotatedlet), instantiates the reference directly viainstantiate_scheme_for_call— the same helper and bound/assoc-eq/ neg-bound checks a direct call already runs — and emits an ordinaryTypedExpr::Ident. NoGenericClosure, nofn_tablelookup, no new runtime representation. - Confirmed:
GenericClosure's existing runtime value already defers instantiation per call. Read directly inmetel-interpreter/src/evaluator/mod.rs: evaluating aTypedExpr::GenericClosureproducesClosureBody::Untyped(body.clone())withtype_ctx: env.type_ctx.clone()— re-typechecked per call site, not baked in at creation. No prerequisite bug existed. - Resolved implicitly, not by an explicit clone-vs-share decision.
The inference-side widening re-generalizes
alias's own already-fresh, unconstrained auto-instantiation via the samegeneralize()call the closure case already used — this naturally produces an independent scheme (fresh quantified vars viageneralize's own renaming), with no separate cloning logic to write. - §735 interaction, checked: not exercised further here — flagged as a
follow-up rather than verified end-to-end;
#735's own issue should be re-checked against this RFC's mechanism directly, not assumed resolved by this note. - §5's "nested generic functions... needs confirming during
implementation" — confirmed working, via
evaluator/generics/104_generic_fn_nested_bare_reference.mtl: the samefn_table/poly_envmechanism applies unchanged at nested scope. - Scope actually shipped, vs. the RFC's own scope statement: exactly as scoped — a bare reference and a higher-order argument, for both top-level and nested generic functions. Both items in "What stays out of scope" (§5) — a standalone turbofish-without-call value form, and rank-2 polymorphism — remain unimplemented and out of scope, unchanged.
References
- metel-core#736 (this RFC's originating issue, including the acceptance criteria this RFC's scope is drawn from)
- metel-core#735 (found the gap; see Open Question 4)
spec.functions.first-class-functions.legality-2spec.functions.closures(the existing closure let-polymorphism mechanism this RFC extends)- historical let-polymorphism work: #10, #290, #295 (closed)
Decision
Outcome: Accepted, as scoped in §5 (a bare reference and a higher-order argument, for both top-level and nested generic functions; rank-2 positions and a standalone turbofish-without-call form remain out of scope). Implemented in metel-core#844, tracked end-to-end by metel-core#736. Target: v0.13.0