A function may return multiple values via a bare-paren return signature: `-> (A, B)` / `-> (x: A, y: B)` / `-> (A, B, !)` (error always the last slot), and `-> ()` is `void`. This is DISTINCT from a `Tuple(…)` value — return-position only (a dedicated `ReturnTypeExpr` AST node resolving to a reused `.tuple` TypeId); a parameter / field / variable annotation `x: (A, B)` is rejected. A single-value `-> (T, !)` stays a plain failable (= `-> T !`). Returns use the bare comma form `return a, b` / `return x = a, y = b` (no `.( … )` literal). Consume by destructuring (`a, b := f()`) or single-bind + field access (`c := f(); c.sum`); a failable bound value holds only the value slots (the error stays on the `!` channel). Named return slots are in-scope assignable locals; with no explicit `return` the implicit return is synthesized from them. Path-sensitive definite-assignment enforces the must-set rule, and a slot may carry a default that exempts it. Validation rejects arity mismatches, out-of-slot-order named elements, a slot/parameter name collision, a comma list from a single-value function, and a multi-return signature used as a value type. Examples 0202-0213; readme + specs updated. issues/0197 files a pre-existing annotated-assignment type-check gap (`x: i32 = "hi"` segfaults) surfaced by the adversarial review.
9.2 KiB
PLAN-MULTIRET — bare-paren multi-value returns + named returns
Why
sx already has multi-value returns, but only in a verbose spelling:
-> Tuple(A, B) / -> Tuple(x: A, y: B) types and return .(a, b) /
return .(x = a, y = b) tuple-literal returns. Destructuring (a, b := f()),
named/positional field access (r.x / r.0), and value-carrying failables
(Tuple(A, B) !E) all work on top of the existing .tuple TypeId.
The user wants the ergonomic, canonical surface:
a :: () -> () { } // () ≡ void
two :: () -> (i32, bool) { return 42, true; } // bare-paren type + bare comma return
b :: (f1: i32, f2: i32) -> (sum: i32, good: bool) { // named returns are in-scope locals
good = true;
sum = f1 + f2; // implicit return: all named slots set
}
b2 :: (f1: i32, f2: i32) -> (sum: i32, good: bool) {
return f1 + f2, f2 > 42; // bare comma return still works
}
read :: () -> (i32, bool, !) { ... } // error channel ALWAYS the last slot
Rules (from the user):
() -> ()≡() -> void.- A multi-return signature is NOT a tuple — it just REUSES the tuple machinery.
-> (i32, bool)/-> (x: i32, y: bool)mean "this function returns multiple values", a DISTINCT thing from-> Tuple(i32, bool)(which returns one tuple value). The bare-paren form is valid ONLY as a function/closure RETURN signature —x: (A, B)(a variable/param/field annotation) stays REJECTED;Tuple(…)is the spelling for an actual tuple value type. - Consumption — destructure OR single-bind (REVISED 2026-06-27). A
multi-return result may be DESTRUCTURED (
s, g := b2()) OR bound to a single name and reached by field (c := b2(); c.sum/c.0). The earlier destructure-only rule (single-bind = error) was REVERSED by the user — single binding is allowed; the bound value behaves like a tuple of the value slots.- Failable: the error stays SEPARATE. For
-> (sum, good, !), a bound value (c := f() catch …/try) holds ONLY the value slots — the error rides the!channel and is NEVER part ofc(noc.err). This falls out of the existing failable machinery (catch/try strip the error before binding).
- Failable: the error stays SEPARATE. For
- Failable: the error channel is always the LAST slot (
(A, B, !)). - Bare comma return:
return v1, v2;maps positionally to the return slots — no.(…)tuple literal needed. - Named returns are assignable locals. With no explicit
return, an implicit return at end-of-body synthesizes the result from the named locals. A named return that is neither assigned on the path nor given a default is a COMPILE ERROR. A named slot may carry a default ((sum: i32 = 0, good: bool)); a defaulted slot needn't be assigned.
Representation (how "not a tuple, reuse machinery" is realized) — AS BUILT
- A dedicated AST node
ReturnTypeExpr(field_types+ optionalfield_names, same shape as a tuple) is produced by the parser for a bare-paren result list with ≥2 value slots ((A, B),(x: A, y: B),(A, B, !)). A single-value(T, !)stays atuple_type_expr(a plain failable,= -> T !). An EMPTY()parses to thevoidtype. - It resolves (type_resolver
internTupleLike, shared withtuple_type_expr) to a reused.tupleTypeId — full ABI / failable / destructure / field-access machinery reuse. Its distinct MEANING lives in the AST node, not the TypeId. - Position gating: the node is valid only in a return slot.
resolveParamTyperejects aReturnTypeExprparameter annotation ("multi-return is return-only; use Tuple(…)"). Being a distinct node, its mere appearance in a value-type position is categorically an error (no flag to check) — exhaustiveswitches overnode.datawere forced to add a.return_type_exprarm (coverage). - Consumption: destructure (
a, b := f()) or single-bind + field access (c := f(); c.sum). No single source of truth needed at call sites — the result is just a tuple value. - SCOPE: multi-return on
name :: (...) -> (…) { }function declarations first. Multi-return CLOSURE-TYPE values (cb: Closure() -> (A, B)) and lambda literals are a later phase.
What already exists (re-use, do NOT rebuild)
tuple_type_expr→.tupleTypeId with optionalnames(type_resolver.zigresolveCompound).- Named + positional tuple field access
r.x/r.0(expr.ziglowerFieldAccessOnType). - Destructuring
a, b := f()(DestructureDecl, stmt.zig). - Value-carrying failable assembly
(T1, …, !)(error.ziglowerFailableSuccessReturn/emitTupleRet) — error in the last slot. return .(a, b)/return .(x = a, y = b)tuple-literal returns (stmt.ziglowerReturn).- Generic inference through a failable/tuple closure return (this session's
parser
collectGenericNames+ generic.zigextractTypeParamtuple arms).
Foundation already landed (uncommitted, suite-green)
- parser.zig —
collectGenericNamesdescends tuple/optional/function nodes (soClosure() -> $R !binds$R); the bare-paren result-list path builds a failabletuple_type_exprwhen it ends in!((A, B, !)parses). - generic.zig —
extractTypeParam/matchTypeParam[Static]handle the(value, !)tuple so$Rinfers from a closure ARG's failable return.
Phases (each: implement → lock with an example → zig build test green)
-
() -> ()= void (parser). Isolated, unambiguous. An empty()in the paren type path resolves tovoid. Lock:a :: () -> () { }. -
Multi-return signatures
-> (A, B)/-> (x: A, y: B)/-> (A, B, !)(parser + AST + resolution). Add themulti_return_typeAST node; the parser produces it for a bare-paren result list (return position). The return resolver lowers it to a.tupleTypeId and setsFunction.multi_return; the general resolver rejects it (return-position only). Returns still use the existingreturn .(…)literal in this phase (bare comma is Phase 2). Consumption is destructuringa, b := f()(existing machinery). Lock: positional + named + failable multi-return examples, each destructured. -
Destructure-only enforcement + bare comma
return v1, v2(parser + lowering). (a) Reject using a multi-return call as a single value (r := f(), an arg, an operand) — readFunction.multi_returnat the binding/use site; only destructuring is allowed. (b) Extend the return statement to parse a comma-separated value list and lower it to the same multi-slot return the.(…)literal produces (error slot stays implicit for failables). Single-valuereturn vunchanged. Lock:-> (i64, bool) { return 7, true; }, a failable variant, and a negative example (r := f()→ diagnostic). -
Named-return locals + must-set rule (sema/lowering). For a named return
-> (x: A, y: B), bind each name as an in-scope assignable local (alloca). On a path that reaches end-of-body with NO explicitreturn, synthesize the implicit return from the named locals. Diagnose loudly if any named slot is neither assigned on that path nor defaulted (no silent zero-fill). Explicitreturn v1, v2/return .(…)still override. Lock: theb :: (...) -> (sum, good) { good = true; sum = ... }example + a negative example (unset slot → diagnostic). -
Named-return defaults
(sum: i32 = 0, good: bool). A slot with a default is exempt from the must-set rule; the default fills it at the implicit (or partial explicit) return. Lock: an example mixing a defaulted + a required slot.
Open decisions (Decisions Log)
- D1 — multi-return is NOT a tuple; return-position-only. Chosen (user
directive). Realized via a distinct
ReturnTypeExprAST node (the user preferred a dedicated node over aTupleTypeExpr.is_multi_returnflag — it makes "not a tuple" true at the AST level and makes position-gating categorical) that resolves to a reused.tupleTypeId. A new.tuple-like TypeInfo variant was rejected — it would ripple through every exhaustive type switch for no ABI benefit. Destructure-only was REVERSED (see Rules): single-binding a multi-return result is allowed (field access on the value slots); the failable error stays on the separate!channel. - D2 (Phase 3) — storage for named-return locals. Lean: an alloca per named slot bound in the function scope under its name; the implicit return reads them into the result tuple. Revisit if the must-set analysis wants SSA-style definite-assignment instead of an alloca + per-path check.
- D3 — multi-return closure-type values / lambda literals. Deferred past the
function-decl phases (needs a
ClosureInfo.multi_returnflag). Phases 0–4 cover named function declarations only.
Validation (every phase)
zig build && zig build testgreen (full corpus).- New
examples/<category>/…locked with snapshots; review the diff for.irchurn only where expected (the prelude type table is untouched by this stream, so churn should be minimal/none). - Adversarial review of each phase before it lands.
Category for examples
Multi-return is a core type/return feature — use the types block (01xx),
next free numbers, unless a better fit emerges.