plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-24-enum-discriminant-values-design.md
# Enum Discriminant Values — Design Spec
## Goal
Let an enum declare one or more shared, uniformly-typed fields that every
variant supplies a concrete value for, readable via ordinary field access
(`self.n`) regardless of which variant a given instance actually is:
```
enum Step(n: Int) =
| READ_MIN_OCCURANCES(0)
| READ_MAX_OCCURANCES(1)
| READ_CHAR_TO_COUNT(2)
| COUNT_OCCURANCES(3)
```
Here every `Step` value carries an `Int` field `n`; `READ_MIN_OCCURANCES.n`
(or `self.n` inside a method) reads `0` without needing a `match`. This is
different from today's per-variant payload (`Some(a)`/`Ok(a)`), where each
variant's payload has a different shape and can only be read by destructuring
via `match`.
## Non-goals
- No mixing of enum-level discriminant params with today's per-variant
generic-type payload (`| Some[T]`) in the same enum — an enum is either
a "discriminant enum" (this feature) or an ordinary/generic enum
(today's feature), not both, for v1. If a real use case for combining them
shows up later, that's a follow-up.
- No change to how ordinary enums (`Option`, `Result`, `Bool`, `Color`, ...)
are declared, checked, or compiled — this is purely additive.
- No automatic derivation of a discriminant from declaration order (that's
`plum-checker`'s existing internal *tag* numbering, which already exists
and is unrelated — see Current State). This feature is about a
user-declared, user-typed, user-readable field, not the internal tag.
## Current state (relevant constraints found during design)
This is the key architectural fact the design has to work around:
- **Payload-free variants are bare integers today, not heap values.** A
nullary variant like `None`/`True`/`Red` compiles to a plain `i32.const`
with **no heap allocation** (`plum-wasm-codegen`'s
`compile_variant_construction`: `if field_types.is_empty() { return
I32Const(tag) }`). This is a real optimization the checker/codegen rely on
elsewhere (pattern-match codegen explicitly branches on "is this variant a
small int or a heap pointer").
- **Payload variants are heap-allocated as `[tag: i32][field0][field1]...`**,
each slot at an 8-byte stride, sized independently per variant (no
"widest variant" padding).
- **Field access (`self.field`) is class-only today.** It requires the
receiver's type to resolve to a `Class` in `ClassEnv`, looks up the
field's positional index, and loads at `field_idx * 8` from the class's
heap pointer. Enums have no entry in `ClassEnv` and are never consulted
by this code path today.
- **A bare variant reference used as a value** (`None`, `True`, `Red` with
no explicit constructor call) always compiles to just the constant tag
integer — there is no existing mechanism for a bare reference to also
carry an accompanying value.
The consequence: a variant that carries a discriminant field **cannot** use
today's bare-int optimization — if `READ_MIN_OCCURANCES` compiled to a plain
`i32.const 0` tag, there'd be nowhere to also store the field value `0`
itself (and no way to tell "tag" from "field value" if they happened to
collide numerically for a different variant). So a discriminant enum's
variants must **always heap-allocate**, exactly like today's payload
variants — this feature reuses that existing scheme rather than inventing a
new one.
## Design
### Grammar
```js
enum: ($) =>
seq(
"enum",
field("name", $.type_identifier),
field("params", optional(seq("(", commaSep1($.enum_param), ")"))),
"=",
$._indent,
optional(repeat(alias($.enum_field, $.field))),
$._dedent,
),
enum_param: ($) =>
seq(field("name", $.var_identifier), ":", field("type", $.type)),
enum_field: ($) =>
seq(
"|",
field("name", $.type_identifier),
field(
"parameters",
optional(choice(
seq("[", commaSep1(choice($.type_identifier, $.generic)), "]"), // existing: generic type payload
seq("(", commaSep1($.expression), ")"), // new: discriminant value literals
)),
),
),
```
The bracket form (`[...]`, existing) and the paren form (`(...)`, new) are
distinguished by delimiter, matching the rest of the language's convention
post-migration: brackets for type-level content, parens for value-level
content — a discriminant value list is values, not types, so it naturally
takes parens, the same delimiter a constructor call already uses for its
arguments.
### AST
```rust
pub struct Enum {
pub name: String,
pub params: Vec<EnumParam>, // new; empty for every enum declared without "(...)"
pub variants: Vec<EnumVariant>,
}
pub struct EnumParam { // new
pub name: String,
pub ty: Type,
}
pub struct EnumVariant {
pub name: String,
pub fields: Vec<String>, // existing: generic/type payload names, unchanged meaning
pub values: Vec<Expr>, // new: discriminant literal values, empty for ordinary variants
}
```
A variant has either a non-empty `fields` (today's generic payload) or a
non-empty `values` (this feature), never both — enforced by the checker
(see below), not the grammar (the grammar can't tell a bare type name from a
literal expression apart at the same position without this kind of
cross-check, so it accepts either shape structurally and the checker
rejects the mixed case with a clear error).
### Checker
- **Declaration-time validation:** for an enum with non-empty `params`,
every variant must supply exactly `params.len()` values, and each value's
inferred type must unify with the corresponding param's declared type
(e.g. the literal `0` against `Int`). A variant supplying the wrong count,
or a value of the wrong type, is a checker error. An enum with EMPTY
`params` must have every variant's `values` also empty (this is what "an
enum is either a discriminant enum or an ordinary enum" means in
practice) — a variant with non-empty `values` on a param-less enum is
also a checker error (most likely to happen by a typo like accidentally
writing `Some(5)` instead of `Some[Int]` for an ordinary generic enum;
the error should say so plainly).
- **Field access (`self.n`, `obj.n`):** extend the existing field-access
check (today: receiver type must resolve to a `Class` in `ClassEnv`) with
a second lookup path: if the receiver's type resolves to an `Enum` name
that has non-empty `params` (tracked in a new `EnumParams` map alongside
the existing `EnumVariants`/`ClassEnv` context maps), resolve the field
by name against that enum's declared params exactly as a class field
would be resolved — same "field not found" error shape, just checked
against `EnumParams` instead of `ClassEnv` when the name isn't a class.
Field access on an enum with EMPTY params still falls through to today's
behavior (class-only, or the permissive "unmodeled type" escape hatch)
— unchanged.
### Codegen
- **Construction of a bare discriminant-variant reference**
(`READ_MIN_OCCURANCES` used as a value, no explicit call): today this
compiles `Expr::TypeName(n)` to a bare `I32Const(tag)` when the variant
is payload-free. For a variant belonging to a discriminant enum (non-empty
declared `values`), codegen instead reuses the EXISTING payload-variant
construction path (`compile_variant_construction`) directly, using the
variant's own declared literal `values` (compiled as constants) as the
"field values" to store — structurally as if the user had written
`READ_MIN_OCCURANCES(0)` explicitly at every use site, except the `0`
comes from the variant's declaration, not the call site (there is no call
site; syntactically it's still a bare reference). This means these
variants always heap-allocate, matching the Current State constraint
above — the bare-int optimization is simply not applied to them.
- **Field access codegen (`self.n`):** mirrors the existing class field-load
codegen exactly (`I32Load`/`I64Load`/`F64Load` at `(field_idx + 1) * 8`
from the heap pointer — `+1` because slot 0 is always the tag, matching
today's payload-variant layout) — the only change is resolving
`field_idx`/width from the enum's declared params (via the new
`EnumParams` lookup) instead of `ClassEnv`, when the receiver's static
type is a discriminant enum rather than a class.
- **Pattern matching** (`match` against a discriminant-enum value by variant
name, e.g. `match step \n READ_MIN_OCCURANCES => ...`) is unaffected —
since these variants are now always heap pointers (never bare ints), the
existing payload-variant tag-load-and-compare path in match codegen
applies uniformly, with no need for the "is this a small int or a
pointer" branch discriminant enums currently force ordinary enums to
have (every variant of a discriminant enum is a pointer, so that branch
is simply never taken for this kind of enum).
### Testing strategy
- Tree-sitter corpus: a discriminant-enum declaration (`enum Step(n: Int) =
| READ_MIN_OCCURANCES(0) | ...`), confirming the new `enum_param` and the
paren-form `enum_field` parameters parse into the expected tree; confirm
today's bracket-form (`| Some[T]`) and truly nullary (`| Red`) variants
are unaffected.
- `plum-checker` tests: correct arity/type-checked discriminant values pass;
wrong arity, wrong value type, and mixing discriminant values into a
param-less enum's variant each produce a clear error; `self.n` field
access on a discriminant-enum method type-checks and resolves to the
right type; `self.n` on an ordinary enum (no declared params) still
errors exactly as it does today.
- `plum-wasm-codegen` tests: a method that reads `self.n` off a
discriminant-enum value returns the declared literal for each variant
(covering at least two different variants, to prove the field is
correctly positioned per-instance, not hardcoded to one variant's
value); confirm a discriminant-enum value used in `match` (destructuring
by variant name, not by field) still resolves and runs correctly,
exercising the "always a pointer, never a bare int" codegen path noted
above.