plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-20-generic-enum-multi-instantiation-design.md
# Generic enums: support more than one concrete instantiation per program
## Problem
Generic enum monomorphization (from the prior generics-monomorphization work) has a real,
documented limitation: a given generic enum may only be instantiated at **one** concrete type per
program. Instantiating the same generic enum at two different concrete types (e.g. both
`Option<Int>` and `Option<Str>` needed in the same file) is detected and rejected with a clear
`monomorphize:` error rather than silently corrupting anything — but it's still a real limitation
for exactly the kind of general-purpose `Option`/`Result` a real program would want.
Root cause: `plum_checker::build_global_tables` builds `EnumVariants` — the table matching a
`match` pattern's or a construction call's bare variant name (`"Some"`, `"None"`) to its owning
enum, tag, and field types — keyed by **bare variant name, globally across the whole program**.
Two specializations of the same generic enum (`Option$Int`, `Option$Str`) both declare a variant
literally named `Some`, so they'd collide in that flat table. The existing monomorphizer detects
this collision and rejects it rather than letting one specialization silently clobber the other's
registration — correct, but overly conservative.
## Fix
Mangle variant names too, using the same suffix as their enum's own mangled name — `Some` →
`Some$Int` / `Some$Str`, `None` → `None$Int` / `None$Str`. This is necessary even for
payload-free variants like `None`: although their runtime representation (a small tag, no
payload) is identical regardless of the concrete type argument, their *static type* differs per
instantiation (a bare `None` used as a value must type as `TNamed("Option$Int")` or
`TNamed("Option$Str")`, not an ambiguous, instantiation-independent `Option`), so leaving them
unmangled would still create real inference ambiguity even though it wouldn't corrupt anything
at the runtime-representation level.
This touches three points, all inside the already-built `plum_checker::monomorphize` pass — no
new pipeline integration is needed, since `monomorphize_source` already runs ahead of both
`check_source` and `compile_source`:
1. **`specialize_enum`**: extend it to rename each variant using the same mangled suffix as the
enum's own name, not just the enum itself.
2. **Construction call-site rewriting** (`resolve_enum_instantiation`): currently deliberately
does *not* rewrite `call.name` for a variant constructor like `Some(5)`, on the theory that
variant names stay bare. That has to change now: once the enum's own instantiation is
resolved, also rewrite the construction call's name from `"Some"` to its mangled form.
3. **Match-pattern rewriting** (new): `rewrite_stmt`'s existing `Match` handling infers the
subject's type (already needed for binding a non-variant pattern name) but never touches the
patterns themselves. It needs to: check whether the inferred subject type is a specialized
generic enum, and if so, rewrite every `Some`/`None`-shaped pattern (`ast::CasePattern::Name`
for a payload-free tag, `ast::CasePattern::Class` for a constructor pattern) in that match's
cases to the corresponding mangled variant name, using a per-enum variant-mangling table
recorded at the moment that enum specialization was produced.
With variant names uniquely mangled per specialization, the existing collision-detection
machinery (the `enum_variant_owner` ownership table, and the error path it guards) becomes
unnecessary and should be removed — there is no more collision to detect, since every
specialization's variants live under their own unique mangled names.
## Addendum: ordinary functions with a bare generic-typed param must be specialized too
Implementation surfaced a real, blocking gap the above design didn't account for, discovered
because it broke the **pre-existing** single-instantiation codegen test: an *ordinary* function
(one with no lowercase-letter generic params) that takes a bare generic-enum-typed parameter — the
completely normal way to write this, e.g.
```plum
unwrapOr(o: Option, default: Int) -> Int =
match o
Some(v) => v
None => default
```
— never has its own param type resolved to a concrete specialization at all. `o`'s declared type
stays the literal, unmangled `Option`, so inside `unwrapOr`'s body, `match o` infers a subject
type of `TNamed("Option")` — a key that can never match the mangling table (keyed by the mangled
name, `"Option$Int"`). This isn't an ordering bug (verified directly, including by reordering
source); it's that ordinary functions are never treated as needing specialization at all today,
even though a bare generic-enum-typed param makes them behave exactly like a specialization
target. The same root cause almost certainly affects generic **classes** too, for the identical
shape (`f(b: Box) -> Int = ...`) — untested before now only because no existing test happened to
exercise it, but the failure mode is analogous (post-monomorphization, the bare generic class
`Box` no longer exists in the output at all, since only its mangled specializations survive).
**Fix, symmetric for both classes and enums:** treat an ordinary function whose param or return
type bare-names a generic class or enum as needing its own per-call-site specialization, exactly
mirroring how a truly-generic function (lowercase-letter params) is already specialized:
- **Return position** is half-solved already: `maybe_rewrite_return` already resolves a bare
generic-*class* return (e.g. `-> Box`) from the body's inferred tail type, via the existing
`self.classes_generic.contains_key(&rt.name)` check — it just never checked the enum registry.
Add the parallel `self.enums_generic_by_name.contains_key(&rt.name)` check alongside it.
- **Param position** (the actual blocker) is new: detect, for an otherwise-ordinary function,
every param whose declared type bare-names a generic class or enum (a new `fn_bare_generic_refs`
helper, parallel in spirit to `fn_generic_params` but checking against the classes/enums
registries instead of the lowercase-letter convention). Such a function is deferred (not passed
through directly) and resolved at each call site: infer the calling argument's already-rewritten
concrete type (e.g. the first argument to `unwrapOr(Some(13), 0)` is, after its own construction
site is rewritten, `TNamed("Option$Int")`), bind `{"Option": TNamed("Option$Int")}` into a
`Substitution`, mangle the function itself (`mangle("unwrapOr", &[TNamed("Option$Int")])` ->
`"unwrapOr$Option$Int"`), and specialize it via the **existing, unmodified** `specialize_fn` —
its substitution mechanism already walks and replaces any type whose bare name matches a
substitution key, so no changes to `specialize_fn` itself are needed, only to how the
substitution is discovered and populated for this new case.
Scoped to free functions only for this fix — a **method** introducing this same shape (a method
on a non-generic class whose own param bare-names a generic class/enum) is a further edge case,
consistent with the existing "a method introducing its own additional generic parameter" already
being out of scope. A function that is simultaneously truly-generic (lowercase letters) *and*
bare-references another generic type is also out of scope for now (no current example needs it).
## Testing plan
- **Checker tests**: a generic `Option`-shaped enum instantiated at two different concrete types
in the same program (both `Option<Int>` and `Option<Str>` constructed and matched) type-checks
correctly, each `match`'s patterns resolving against the correct specialization.
- **Codegen tests**: the same two-instantiation scenario, compiled and executed via `wasmtime`,
confirming both specializations produce correct, non-aliasing results (mirroring the existing
"generic class specialized at two types" test's shape, now extended to enums).
- Re-verify (don't just assume unaffected): a single-instantiation generic enum still works
exactly as before now that the collision-guard code path is gone — the existing
single-instantiation tests from the prior plan must still pass unchanged.
- Remove or repurpose the now-obsolete "multi-instantiation collision is a clear error" test from
the prior plan, since that behavior is being replaced by "multi-instantiation now works
correctly" — replace it with the new coexistence test above.
- The exact regression that surfaced the addendum's gap: an *ordinary* function taking a bare
generic-enum-typed parameter (`unwrapOr(o: Option, default: Int) -> Int`), compiled and run via
`wasmtime`, at both a single instantiation (proving no regression to the already-working case)
and multiple coexisting instantiations. Add the analogous test for a generic **class** (an
ordinary function taking a bare generic-class-typed parameter, e.g. `sumBox(b: Box) -> Int`),
since the addendum's fix is symmetric and this shape was never tested before.
## Out of scope
- Everything else the generics-monomorphization plan already scoped out (a method introducing
its own additional generic parameter; trait-bound enforcement; `libs/std` compiling as-is) —
unchanged, unaffected by this fix.
- Closures and the `list.plum`/`map.plum` rewrite — separate, subsequent follow-ups.