Files
sx/current/PLAN-MULTIRET.md
agra 76689a1ea6 feat: multiple return values — bare-paren signatures, named returns, must-set, defaults
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.
2026-06-27 12:31:23 +03:00

9.2 KiB
Raw Blame History

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 of c (no c.err). This falls out of the existing failable machinery (catch/try strip the error before binding).
  • 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 + optional field_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 a tuple_type_expr (a plain failable, = -> T !). An EMPTY () parses to the void type.
  • It resolves (type_resolver internTupleLike, shared with tuple_type_expr) to a reused .tuple TypeId — 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. resolveParamType rejects a ReturnTypeExpr parameter 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) — exhaustive switches over node.data were forced to add a .return_type_expr arm (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.tuple TypeId with optional names (type_resolver.zig resolveCompound).
  • Named + positional tuple field access r.x / r.0 (expr.zig lowerFieldAccessOnType).
  • Destructuring a, b := f() (DestructureDecl, stmt.zig).
  • Value-carrying failable assembly (T1, …, !) (error.zig lowerFailableSuccessReturn / emitTupleRet) — error in the last slot.
  • return .(a, b) / return .(x = a, y = b) tuple-literal returns (stmt.zig lowerReturn).
  • Generic inference through a failable/tuple closure return (this session's parser collectGenericNames + generic.zig extractTypeParam tuple arms).

Foundation already landed (uncommitted, suite-green)

  • parser.zigcollectGenericNames descends tuple/optional/function nodes (so Closure() -> $R ! binds $R); the bare-paren result-list path builds a failable tuple_type_expr when it ends in ! ((A, B, !) parses).
  • generic.zigextractTypeParam / matchTypeParam[Static] handle the (value, !) tuple so $R infers from a closure ARG's failable return.

Phases (each: implement → lock with an example → zig build test green)

  1. () -> () = void (parser). Isolated, unambiguous. An empty () in the paren type path resolves to void. Lock: a :: () -> () { }.

  2. Multi-return signatures -> (A, B) / -> (x: A, y: B) / -> (A, B, !) (parser + AST + resolution). Add the multi_return_type AST node; the parser produces it for a bare-paren result list (return position). The return resolver lowers it to a .tuple TypeId and sets Function.multi_return; the general resolver rejects it (return-position only). Returns still use the existing return .(…) literal in this phase (bare comma is Phase 2). Consumption is destructuring a, b := f() (existing machinery). Lock: positional + named + failable multi-return examples, each destructured.

  3. 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) — read Function.multi_return at 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-value return v unchanged. Lock: -> (i64, bool) { return 7, true; }, a failable variant, and a negative example (r := f() → diagnostic).

  4. 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 explicit return, 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). Explicit return v1, v2 / return .(…) still override. Lock: the b :: (...) -> (sum, good) { good = true; sum = ... } example + a negative example (unset slot → diagnostic).

  5. 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 ReturnTypeExpr AST node (the user preferred a dedicated node over a TupleTypeExpr.is_multi_return flag — it makes "not a tuple" true at the AST level and makes position-gating categorical) that resolves to a reused .tuple TypeId. 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_return flag). Phases 04 cover named function declarations only.

Validation (every phase)

  • zig build && zig build test green (full corpus).
  • New examples/<category>/… locked with snapshots; review the diff for .ir churn 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.