UFCS combinators — extension methods with an error channel and method-level type parameters¶
Status: SHIPPED (the UFCS epic, 2026-08-30). This is the decision record. The seven rulings here are David's (2026-08-29 and 2026-08-30) and are settled.
The headline: the <collections/iter> combinators exist in method form, written in
Sushi, shipped in the stdlib, on a general language feature users can also write:
extend List@(T) map@(U)(fn(T) -> U f) List@(U) | StdError:
let List@(U) out = List.new()
foreach(x in self.iter()):
out.push(f(x)??)
return out # bare success; only Err is spelled (ruling 6)
Call site: xs.map(|i32 x| x * 2)??. Targets now: List@(T) and T[]. The free
functions stay.
The concept¶
Before this epic, the error discipline followed the KIND of callable: a free function
has the Result channel, an extension method does not. The channel now follows the
SIGNATURE: a method that declares | E has it, a method that does not stays bare. That
matches the parameter-mode philosophy — marked at both ends: | E at the declaration,
?? at the call. It also removes a built-in privilege: the built-in methods already
return Result/Maybe (arr.get(i), xs.pop()), and users could not write one that
does. Now they can.
The rulings¶
1. Error channel: opt-in and explicit¶
The default stays the bare return. Only a method that declares | E gets the Result
ABI, ?? in the body, and the channel at the call. Bare-return extensions keep CE0131
(no ??) and CE2091 (no Result constructors) byte for byte.
2. Name claim: accepted¶
A stdlib extension is an ordinary extension. The resolution ladder — built-in > perk >
extension, CE0101 between extensions — is untouched. A user extension named map on
another type coexists; a second map on the SAME target is the ordinary CE0101.
3. Arrays: the element position binds¶
A bare undeclared name in the element position of an array target binds a type
parameter: extend T[] applies to every element type. A declared or built-in name is
concrete: extend i32[], extend Crate[]. Anything else in that position — a generic
instantiation, a nested array — is CE2101. (This ruling also fixed a latent bug: a
concrete extend i32[] was silently never registered before.)
4. Scope: List and T[] now; HashMap later¶
The stdlib method form ships on List@(T) and T[] in <collections/iter>. A HashMap
module comes later, separately.
5. Chain semantics (the matrix)¶
A channel method stops the chain until it is handled; ?? re-enables it; methods ON
the wrapper stay legal; ?? on a bare method is an error:
| chain | verdict |
|---|---|
b.is_true().is_false() |
CE2515 — the channel is unhandled |
b.is_true()??.is_false() |
compiles |
b.is_false().is_true() |
compiles — bare chains freely |
b.is_false()??.is_true() |
CE2507 — ?? on a bare bool |
b.is_true().realise(false).is_false() |
compiles — .realise answers from the wrapper |
CE2515 is a RESOLUTION FALLBACK, not a receiver-kind ban. Resolution runs first: a
method found on the Result/Maybe enum itself is legal. CE2515 fires only when the
method is missing there but present on the payload type — which is what tells an
unhandled channel from a typo. The diagnostic is relational (it names the missing
method and the call that returned the wrapper) and its help spells the ?? fix. It
covers result-like AND maybe-like receivers: a Maybe@(T) is also more than the bare
T, so xs.find(p).len() is the same CE2515.
6. Return form in a channel body: bare success, explicit Err¶
return true auto-wraps into Ok at the emission seam. return Result.Err(e) is the
ONE spelled constructor. return Result.Ok(x) stays refused — CE2091, narrowed to the
Ok constructor in a channel body, while a bare body keeps refusing both constructors.
This is the first written rationale for the bare-return rule itself: the error is the
exceptional path and earns its ink; the success stays as light as a bare method's. A
body that spells Result.Ok around every success return says nothing the signature
did not already say. Free functions are UNCHANGED: they spell Result.Ok explicitly
(CE2030), and CW2511 for ?? in main() stays.
7. ?? on Maybe stays, and is now recorded¶
A None under ?? propagates as a payload-free Result.Err (CE2508's doc states it;
the emission is backend/expressions/try_expr.py). Stated plainly: Maybe is data,
Result is the channel, and ?? converts absence into an empty error. This was already
true; the epic records it as design rather than accident.
Identity and the symbol¶
Two different solved Us on one receiver are two methods, so the symbol carries three
parts: the receiver, the method name, and the method-level type arguments. ONE helper —
extension_symbol(receiver_display, method, margs) in
semantics/generics/name_mangling.py — answers for all three consumers: the
declaration (backend/functions/helpers.py), the call site
(backend/expressions/calls/dispatcher.py, which reads the typecheck pass's
callee_method_type_args stamp instead of re-deriving), and the weak_odr dedup. The
__{margs} suffix appears only when method-level arguments exist, so every
pre-existing extension symbol is unchanged. An array receiver folds to
arr__<element>, because [] is not a symbol character.
There is NO third dimension in the ExtensionTable: resolution answers from the
template plus unification, and concrete per-margs copies exist only as ExtendDef nodes
in monomorphized_extensions, deduped by a worklist keyed (receiver, method, margs).
Call-site-driven monomorphization¶
An array template's element and a method-generic's solved arguments exist only at the
call, so the typecheck pass queues instantiations while it resolves calls, and the
analyzer monomorphizes and checks the copies in a bounded fixpoint round after the
per-unit loop (checking one copy can resolve a call that queues another). A solved
argument can name a type instantiation NOTHING else in the program names
(List@(bool)), and the copy is created after resolve, finite-types and derive
have run — so the analyzer installs a late interner on the tables: the new types are
monomorphized, resolved and derived at resolution time, and again for the copies'
bodies in the drain.
Inference is call-site-only in v1: there is no @(...) slot on a method call. The one
shape that cannot be solved is the bare-param lambda (|x| ... has no type of its
own), and CE2063 names the escape: annotate the parameter, or pass a named function. A
method-level name that repeats a receiver-target parameter is CE2064.
Program-wide extension visibility (the stated asymmetry)¶
Extensions are program-wide and unit-blind: the ExtensionTable keys on the target type
alone, so ONE unit's use <collections/iter> makes .map() callable in every unit.
This is accepted (ruling 2) and stated rather than hidden. Consequence for API
authors: an extension is as visible as its target type, so a module cannot keep
private helper extensions on a public type — internals stay free functions.
The footgun audit¶
Every unhandled-Result position has a gate:
| position | gate |
|---|---|
assignment (let i32 x = f()) |
CE2505 |
chaining (xs.map(f).filter(p)) |
CE2515 |
an if condition |
CE2516 |
| an argument | CE2006 |
| a discarded statement | CW2001 |
?? on a bare method |
CE2507 |
Scope of v1, and what is parked¶
Owned elements: filter ships fully general — it clones each kept element. map
and fold stay copy/primitive-element, like the free functions.
Parked open questions, recorded and not expanded here:
- (a) A bare opt-out for free functions. The remaining asymmetry: a function cannot be infallible. A method chooses its channel; a function cannot decline one.
- (b) The perk-method channel — SHIPPED. A perk method declares
| Eexactly as an extension method does (HANDLES.mdruling R1). The contract and the implementation declare it in the same shape and must agree, and CE0133 is now the relational diagnostic that says so: the primary at the implementation, a note at the contract method. Everything else carried over unchanged —_validate_method_bodyalready read the channel, and the backend already emits every perk method through the extension path. - (c) Extension visibility. See the stated asymmetry above; a visibility marker on extensions is a separate decision nobody has asked for yet.
HashMap combinators come later, in a separate module (ruling 4).