Files
sx/current/PLAN-METATYPE.md

8.1 KiB

PLAN-METATYPE — comptime type metaprogramming (declare / define + reflection)

Goal

Comptime type metaprogramming with the smallest possible compiler surface:

  • declare(name) -> Type — mint a NEW empty (undefined) nominal type NAMED name, returned as a first-class Type handle. The compiler registers the forward type at compile time, so the body can reference it (*Name).
  • define(handle, info) -> Type — fill a declared handle's body from a TypeInfo value, and return the handle (so the one-shot form chains).
  • type_info($T) -> TypeInfo — reflect a type INTO data (the inverse of define's decode). Done for enums (interp.zig:reflectTypeInfo, examples/0619); struct/tuple widening pending.
  • field_type($T, i) -> Type — the i-th field / variant-payload / element type of $T. Done.

These four #builtins in library/modules/std/meta.sx are the entire compiler surface. Every higher-level constructor is plain sx built over declare/define — the compiler knows none of them by name:

// one-shot (non-recursive): declare + define chained, define returns the handle
T :: define(declare("T"), .enum(.{ variants = .[ … ] }));

// recursive: a ctor fn names the forward type via declare, references it as *Name
List :: make_list();
make_list :: () -> Type {
    h := declare("List");
    return define(h, .enum(.{ variants = .[
        EnumVariant.{ name = "cons", payload = *List },   // self-reference
        EnumVariant.{ name = "nil",  payload = void } ] }));
}

// type-fns are ordinary sx (channel result types, etc.)
RecvResult :: ($T: Type) -> Type {
    return define(declare("RecvResult"), .enum(.{ variants = .[
        EnumVariant.{ name = "value",  payload = T    },
        EnumVariant.{ name = "closed", payload = void } ] }));
}

This gates channel result types (RecvResult($T)) and race's synthesized tagged-union (design ../design/execution-evolution-roadmap.md §7 step 3), and replaces a would-be enum($T) language feature.

How it works (the locked design)

  1. Two comptime interp builtins. declare mints an empty tagged_union slot in the type table; define decodes the TypeInfo value (variant-name strings + payload Type-tags) and completes the slot byte-identical to a source enum's buildEnumInfo output, so it flows through enum codegen unmodified. The interp mutates the type table via a mint handle the host sets (setMintTable).
  2. No syntactic constructor recognition. A :: binding or type-fn body that calls a Type-returning fn is comptime-evaluated (evalComptimeType): the expression runs through the interpreter, the declare/define builtins mint the type, and the result type_tag is bound. decl.zig triggers on a non-generic -> Type fn call; instantiateTypeFunction triggers on a type-fn body that returns a define(…) call (or a bodied -> Type helper) — see generic.zig:returnExprMintsType.
  3. Name on declare. declare("Name") carries the name as a compile-time string so preregisterForwardTypes (in evalComptimeType) can register the forward type — and bind it as a type alias — BEFORE the body lowers. That's what makes a *Name self-reference resolve (a Name :: ctor() decl makes Name a const_decl author, so *Name resolves through the forward-ALIAS path; the alias binding, not just the table registration, is what satisfies it). The interp's declare returns the same slot by name; define fills it in place.
  4. Nominal identity rides the existing type-fn mangled-name instantiation cache: RecvResult(i64) at two sites memoizes to ONE TypeId (the body runs once; renameNominalType re-keys the minted type to the mangled name).
  5. Comptime-only, JIT-free. declare/define are interp ops; reaching them at runtime / emit is a hard error.
  6. Undefined-until-defined. declare() mints an undefined slot; using it (construct / match / size) before its define is a loud diagnostic. A pointer to an undefined slot (*Self) is fine — that's what self-reference needs.

Key code anchors

  • Builtins: BuiltinId.declare / .define (src/ir/inst.zig); lowering to callBuiltin (src/ir/lower/call.zig:tryLowerReflectionCall); interp exec + defineEnum + decodeVariantElements (src/ir/interp.zig); mint field + setMintTable.
  • Comptime evaluation: evalComptimeType / renameNominalType (src/ir/lower/comptime.zig); decl trigger fnReturnsTypeValue (src/ir/lower/decl.zig); type-fn trigger returnExprMintsType + instantiateTypeFunction (src/ir/lower/generic.zig).
  • Reflection: field_typefieldTypeOf (src/ir/lower/generic.zig).
  • Surface: library/modules/std/meta.sx (on-demand import — NOT the prelude, to avoid shifting every .ir snapshot).

Cadence (IMPASSIBLE)

No commit may both add a test AND make it pass (xfail-then-green, or a behavior lock). zig build && zig build test after every step. Never regenerate snapshots while red. Examples: 06xx (comptime), 11xx (diagnostics).

Status

  • declare / define comptime builtins + the mint plumbing.
  • Comptime evaluation of a Type-returning :: RHS and type-fn body (the only triggers; no constructor-name knowledge in the compiler).
  • Name-in-TypeInfo; nominal identity via the instantiation cache.
  • field_type reflection (examples/0616).
  • Examples green on the floor: 0614 (one-shot), 0615 (type-fn identity), 0617 (channel result types).
  • Self-reference — recursive enums via declare("Name") + *Name in a constructor fn (preregisterForwardTypes registers the forward type + alias before the body lowers). examples/0618 (recursive *List: construct, match through the pointer, recursive traversal). Mutual recursion / by-value-self-ref rejection fall out of the same mechanism (F5 adds the loud by-value check).
  • make_enum(name, variants: []EnumVariant) — the general enum constructor over a COMPUTED (value, non-literal) variant list. Pure sx in meta.sx; exercises define decoding a value-arg slice. examples/0620 (array-literal local) / 0624 (generic builder).
  • Comptime slice over a non-string aggregatearr[lo..hi] over an array yields a real slice value at comptime (base_ty threaded onto Subslice; open-ended hi folded to the array's static length; subsliceElements). examples/0621.
  • type_info($T) -> TypeInfo — reflect enum/tagged_union/struct/tuple INTO a value (inverse of define's decode); define decodes all three back (defineEnum/defineStruct/defineTuple, dispatched on the TypeInfo tag). Round-trips: examples/0619 (enum) / 0622 (struct) / 0623 (tuple). The reflect/construct triad is complete.
  • Generic type-fn body locals — a generic ($T) -> Type comptime-evaluates its FULL body (prelude statements + return), so a local before the return resolves (createComptimeFunctionWithPrelude / evalComptimeTypeBody). examples/0624.
  • Validation + loud diagnostics — by-value self-reference (checkInfiniteSize, source 1178 + constructed 1182; issue 0139), duplicate variant/field names (1180), declare() never define()d (1181, was a verifySizes panic), and the 0140 bail-surfacing (1179). use-before-define is subsumed by these (no new check needed). Validation story COMPLETE.
  • Comptime List growth (issue 0141, DEFERRED) — List(T).append at comptime bails (two layers: null comptime allocator at scanDecls + *T slot_ptr struct_get). Non-blocking; array-literal locals cover the use case.

Risks / watch

  • Self-ref timingdefine for the two-statement form must complete before any code uses the type's layout; a use-before-define must be a loud diagnostic, not a silent empty enum.
  • Keep declare/define comptime-only: reaching them at runtime is a hard error (emit should bail loudly if one ever leaks into codegen).