Tagged-union enums — variant payloads + binding match + exhaustiveness #56

Closed
opened 2026-08-31 18:11:12 +02:00 by orkun · 1 comment
Owner

Split out of #53 (types phase 3). The one genuinely deep type-system feature from the #1 proposal: extend enum from int constants to a tagged union whose variants carry payloads, and let match destructure and bind them.

Today: enum is named int constants and match is a scalar switch (icmp on i32, with a _ default). option (#53) and result (#46) already give the two most-common concrete sum types as built-ins.

What this needs (why it was not landed additively):

  • Parser — variant payload declarations, e.g. enum Tile { Empty, Wall, Door(int), Portal(int, int) }, and constructor call syntax Door(3).
  • Value ABI — a tagged representation: a tag plus payload storage sized to the widest variant (a small struct / boxed record).
  • match binding — match t { Door(n) => ..., Portal(x, y) => ..., _ => ... } binding payload names in each arm's scope, with the bound types flowing.
  • Exhaustiveness — the proposal wants exhaustive matching, which requires a checking pass. Ludic has no validation pass yet (unknown types lower to ptr, arity/type mismatches are unchecked), so this is the natural companion to introducing one.

This is invasive (parse.ludic + AST + emit for enum decl / construction / destructuring match + a checking pass) and must preserve the bootstrap fixpoint and every golden render, so it deserves its own issue rather than riding along with the additive option/result work in #53.

Interim coverage: the Value.* tree (#44) already models dynamic tagged data (a tag + heterogeneous kids) with a runtime Value.kind match; enum+match covers the int-tag state-machine case.

Split out of #53 (types phase 3). The one genuinely deep type-system feature from the #1 proposal: extend `enum` from int constants to a tagged union whose variants carry payloads, and let `match` destructure and bind them. Today: `enum` is named int constants and `match` is a scalar switch (icmp on i32, with a `_` default). `option` (#53) and `result` (#46) already give the two most-common concrete sum types as built-ins. What this needs (why it was not landed additively): - **Parser** — variant payload declarations, e.g. `enum Tile { Empty, Wall, Door(int), Portal(int, int) }`, and constructor call syntax `Door(3)`. - **Value ABI** — a tagged representation: a tag plus payload storage sized to the widest variant (a small struct / boxed record). - **match binding** — `match t { Door(n) => ..., Portal(x, y) => ..., _ => ... }` binding payload names in each arm's scope, with the bound types flowing. - **Exhaustiveness** — the proposal wants exhaustive matching, which requires a checking pass. Ludic has no validation pass yet (unknown types lower to `ptr`, arity/type mismatches are unchecked), so this is the natural companion to introducing one. This is invasive (parse.ludic + AST + emit for enum decl / construction / destructuring match + a checking pass) and must preserve the bootstrap fixpoint and every golden render, so it deserves its own issue rather than riding along with the additive option/result work in #53. Interim coverage: the `Value.*` tree (#44) already models dynamic tagged data (a tag + heterogeneous kids) with a runtime `Value.kind` match; `enum`+`match` covers the int-tag state-machine case.
orkun added the
proposal
priority:medium
area:types
labels 2026-08-31 18:11:12 +02:00
Author
Owner

Landed in 872f458 (pushed to main).

What shipped

enum now extends from named int constants to a tagged union: a variant may carry a payload, e.g.

enum Tile { Empty, Wall, Door(int), Portal(int, int) }
  • Parser — variant payload declarations (Door(int), Portal(int, int)), stored as N_PARAM kids on each variant node. All-bare enums parse exactly as before.
  • Value ABI — a payloaded enum boxes to a heap record: an i32 tag at offset 0, then one 8-byte slot per payload position (sized to the widest variant). An all-bare enum keeps its zero-cost compile-time-ordinal Name.Variant representation — byte-for-byte unchanged, so every golden render and the bootstrap fixpoint still hold.
  • Construction — by name: Door(3), Portal(x, y), bare Empty. Resolved ahead of the function-call fallback; payloads are coerced to their declared types.
  • match binding — a tagged scrutinee destructures: the match switches on the tag and binds each arm's payload names in a scoped local frame (Door(n) => …, Portal(x, y) => …), with the declared payload types flowing to the bindings.
  • Exhaustiveness (the checking pass) — a tagged match must cover every variant or end in _, else it is rejected at compile time (match on Tile is not exhaustive: variant Door is unhandled). Constructor arity, pattern arity, and binding-form are checked the same way. The checks run where the scrutinee's type is known.

Example

function walk_cost(t: Tile) -> int {
  match t {
    Empty        => { return 1 }
    Wall         => { return 0 }
    Door(n)      => { return 10 + n }
    Portal(x, y) => { return x + y }
  }
}

Verification

  • New regression test selfhost/tests/enums.ludic (nullary + payload construction, single/multi-payload binding, default arms, OR-ed nullary patterns, enum through params/returns, var reassignment) — passes.
  • Full suite green: x test 85/0, x test-tools 30/0, check-impl / check-vocabulary / check-docs clean.
  • The C-free bootstrap fixpoint still holds (out.ll == seed.ll), and all four golden renders (incl. chronorift's plain-enum battle menus) match byte-for-byte.

Docs updated: LANGUAGE.md, docs/language/structure/kw-enum.md, docs/language/control/kw-match.md.

Notes / scope: payload slots are 8 bytes (holds any i32/i64/ptr payload); nested/literal sub-patterns inside a variant (e.g. Door(0)) are intentionally not supported — payload positions bind names. The interim Value.* dynamic-tag path and option/result built-ins remain as-is.

Landed in `872f458` (pushed to `main`). **What shipped** `enum` now extends from named int constants to a **tagged union**: a variant may carry a payload, e.g. ```ludic enum Tile { Empty, Wall, Door(int), Portal(int, int) } ``` - **Parser** — variant payload declarations (`Door(int)`, `Portal(int, int)`), stored as `N_PARAM` kids on each variant node. All-bare enums parse exactly as before. - **Value ABI** — a payloaded enum boxes to a heap record: an `i32` tag at offset 0, then one 8-byte slot per payload position (sized to the widest variant). An all-bare enum keeps its **zero-cost compile-time-ordinal** `Name.Variant` representation — byte-for-byte unchanged, so every golden render and the bootstrap fixpoint still hold. - **Construction** — by name: `Door(3)`, `Portal(x, y)`, bare `Empty`. Resolved ahead of the function-call fallback; payloads are coerced to their declared types. - **match binding** — a tagged scrutinee destructures: the match switches on the tag and binds each arm's payload names in a scoped local frame (`Door(n) => …`, `Portal(x, y) => …`), with the declared payload types flowing to the bindings. - **Exhaustiveness (the checking pass)** — a tagged `match` must cover every variant or end in `_`, else it is rejected at compile time (`match on Tile is not exhaustive: variant Door is unhandled`). Constructor arity, pattern arity, and binding-form are checked the same way. The checks run where the scrutinee's type is known. **Example** ```ludic function walk_cost(t: Tile) -> int { match t { Empty => { return 1 } Wall => { return 0 } Door(n) => { return 10 + n } Portal(x, y) => { return x + y } } } ``` **Verification** - New regression test `selfhost/tests/enums.ludic` (nullary + payload construction, single/multi-payload binding, default arms, OR-ed nullary patterns, enum through params/returns, `var` reassignment) — passes. - Full suite green: `x test` 85/0, `x test-tools` 30/0, `check-impl` / `check-vocabulary` / `check-docs` clean. - The C-free bootstrap fixpoint still holds (`out.ll == seed.ll`), and all four golden renders (incl. chronorift's plain-enum battle menus) match byte-for-byte. Docs updated: `LANGUAGE.md`, `docs/language/structure/kw-enum.md`, `docs/language/control/kw-match.md`. Notes / scope: payload slots are 8 bytes (holds any i32/i64/ptr payload); nested/literal sub-patterns inside a variant (e.g. `Door(0)`) are intentionally not supported — payload positions bind names. The interim `Value.*` dynamic-tag path and `option`/`result` built-ins remain as-is.
orkun closed this issue 2026-09-01 02:08:58 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#56
No description provided.