Skip to main content

Generic Enums End-to-End and the Day for and while Finally Lower

· 7 min read
daily-progress: Part 6 of 11

There’s a guard I’ve been embarrassed about for a couple of weeks. Any time a generic enum value tried to lower, the compiler hit this and gave up:

error[E_NOT_YET_IMPLEMENTED]: generic enum values are not yet supported

It was honest — it failed closed instead of emitting garbage — but it meant Option<i64> was a type the checker understood and the backend refused to build. You could write let x: Option<i64> = None and the type checker would nod along, and then the whole thing fell off a cliff at lowering. Today the cliff is gone. Generic enum values lower the entire way down — registry, instantiation discovery, codegen — and I got to delete the guard.

What was actually missing

The substrate for monomorphic enums has been there for a while. A plain enum Colour { Red; Green; Blue } builds a tagged-union layout, constructs at the right tag, and matches by branching on EqI32(tag, k). The piece that wasn’t there was the bookkeeping for which concrete instantiations of a generic enum a program actually uses.

So the first thing was a registry. It’s keyed on (type_id, mono_args) and mirrors the record-layout registry I already had — each entry carries a layout whose per-variant payload field types have been substituted to concrete types through the monomorphisation pass. Option<i64> and Option<string> are two different keys with two different layouts, the way they have to be.

Then discovery. Lowering walks every generic-enum constructor call site and every match scrutinee, reads the type the checker resolved for that span (from the side table, not from whatever the AST happened to annotate), and feeds the concrete instantiation into the registry. The walk has to be a fixpoint, because instantiations nest: Option<Option<i64>> has to register both Option<i64> and Option<Option<i64>>, and you only find the inner one by looking at the outer one first.

The registry is insertion-ordered and capped at 1024 instantiations. If a program somehow blows past that, it emits a structured diagnostic and aborts that expansion path rather than chewing memory forever. (I do not expect a real program to hit 1024 distinct enum instantiations. But “I do not expect” is not a layout the backend can emit, so there’s a cap.)

With the registry feeding MIR, codegen consumes a per-(type_id, mono_args) layout, and the not-yet-supported diagnostic narrows to fire only when a generic declaration has zero registered instantiations — which is the defensive case where nobody ever used it, and there’s genuinely nothing to build. That’s the only thing it guards now.

Here’s the shape that motivated all of it. A generic enum, constructed and matched, lowering end-to-end:

enum Maybe<T> {
    Nothing;
    Just(T);
}
 
fn unwrap_or(m: Maybe<i64>, fallback: i64) -> i64 {
    match m {
        .Just(x) => x,
        .Nothing => fallback,
    }
}
 
fn main() {
    let present: Maybe<i64> = .Just(42);
    let absent: Maybe<i64> = .Nothing;
    println(unwrap_or(present, -1));   // 42
    println(unwrap_or(absent, -1));    // -1
}

The Maybe::Just(x) arm there is doing real work that didn’t exist a few days ago: it binds x from the payload slot. That came in alongside this — match arms with payload-bearing patterns now lower through HIR and MIR. A tuple-payload variant binds its field, a struct-payload variant binds its named fields. The arm body emits the bind plus a payload move, and sibling arms save and restore their binding locals so one arm’s x can’t leak into the next.

The probe I kept re-running while wiring this was the one from the commit itself — Option<i64>::Some(42), Option<i64>::None, and a struct-payload Maybe<i64>::Just { value: 42 }. All three run clean through hew run with the right values now.

The cross-enum collision I almost shipped

One bug worth writing down. Bare-name constructors — None instead of Option::None — resolve through a function-signature table. Two different enums can both declare a None, and the lookup was matching on the bare name alone, which means it could hand you back the wrong enum’s variant. The fix was to check that the signature’s type name actually matches the qualified prefix before accepting it, and fall back to the bare Named reconstruction on a mismatch.

While I was in there I also stopped reconstructing constructor result types from bare names entirely. The three constructor shapes — tuple, unit, struct — now read the checker-authoritative result type from expr_types[span], with a CheckerBoundaryViolation diagnostic if that side table is somehow absent. The old code rebuilt Named { args: vec![] } from scratch at each site, which is exactly how you lose the type arguments you spent all that registry work preserving.

for and while

The other thing that landed today is almost embarrassing in how overdue it is: for and while statements lower to LLVM control flow. Up to now you could parse a loop and the AST was happy, but there was no path to actual branches and back-edges.

fn main() {
    var total: i64 = 0;
    for i in 1..=5 {
        total = total + i;
    }
 
    var n: i64 = total;
    while n > 0 {
        n = n - 1;
    }
 
    println(total);   // 15
    println(n);       // 0
}

while cond builds a three-block CFG — header, body, exit. for i in start..end builds a four-block one with a checked increment. There are two details in there I’m glad I handled instead of papering over. Inclusive ranges that would step past iN::MAX trap with IntegerOverflow rather than wrapping silently, and empty exclusive ranges where start >= end just skip the body instead of running once.

The interesting part wasn’t the CFG construction, though — it was the dataflow analysis underneath it. It had quietly assumed an acyclic graph this whole time. It visited each block exactly once, in order, which is correct right up until you introduce a back-edge and the value flowing into the loop header depends on the value computed at the bottom of the body. A single pass gives you a stale answer. So the analysis is now a worklist that runs to a fixpoint: reverse-postorder walk, meet over predecessors, skip predecessors you haven’t visited yet so the lattice meet stays monotonic and the thing actually terminates.

for over anything that isn’t a range still fails closed with a typed E_NOT_YET_IMPLEMENTED — iterating an arbitrary collection is a separate piece I haven’t built. Ranges are the common case and they’re the case that’s done. (Update, June 2026: for over collections has since landed — for x in xs over a Vec works on the v0.5.0 candidate.)

Tail-position match becomes the return value

There’s a small thing that makes the whole language read better. A match (or an if) in tail position is the function’s return value, with no explicit return:

enum Shape {
    Point;
    Line(i64);
    Box { w: i64, h: i64 };
}
 
fn area(s: Shape) -> i64 {
    match s {
        .Point => 0,
        .Line(len) => len,
        .Box { w, h } => w * h,
    }
}

That area function has no return keyword in it. The trailing match is the result. Each arm contributes its body expression as the value, and the value flows straight out. The mixed-shape enum there is also worth a glance — unit, tuple-payload, and struct-payload variants in one declaration, with the tag width and payload size sized to the widest variant, all dispatched and destructured by a single arm-chain.

Guards on match arms — Maybe::Just(x) if x > 10 — are still rejected at the lowering boundary. That’s the next arm shape to wire, and for now it fails closed like everything else I haven’t built yet. (Update, June 2026: guard arms landed in v0.5.0 — this exact shape included. What’s still waiting is a guard on a match over a plain record or tuple value, which lowers as a single irrefutable destructure with no later arm to fall through to.)

Wire payloads, generalized

A smaller change in the same stretch: Sink<T> and Stream<T> used to carry a hardcoded allowlist of payload types — string, bytes, and a couple of others. That’s now a structural check. Any T the checker can prove is Encode + Decode admits, which includes user records, since they auto-derive both. A type that’s Send + Sync + Copy + Clone but deliberately not wire-serialisable — LocalPid<Actor> is the example — rejects with a diagnostic that names exactly which marker pair is missing. The allowlist was a placeholder; the marker pair is the real rule.

Writing the rules down

The last thing today wasn’t code so much as making the code’s intentions legible. The compiler has a failure philosophy that’s mostly lived in my head and in the shape of the substrate, and I pinned three pieces of it.

Cascade: when a linked actor crashes, its exit propagates to its links before the supervisor’s restart decision completes. The substrate already did this; there’s now an integration test that captures independent timestamps on separate threads and would fail if the ordering ever inverted. Visibility: a reserved CrashNotification payload so a linked actor receives a distinct, structured signal — actor id and crash kind — rather than guessing. And the trap boundary: a docstring that finally writes down the two-tier rule the signal handler has always followed — recoverable faults delivered to a worker thread with a valid jump buffer versus program-terminating double-faults with no recovery context.

None of that changed behaviour. It’s the difference between a compiler that happens to fail closed and one where you can read why it fails closed without spelunking through the runtime. That distinction matters more to me than any single feature: a compiler you can’t reason about is one you can’t trust.

Hew Notes

Language development, engineering articles, releases and community projects. About once a month, with an extra issue for a significant release. Written in English.

Subscribe to Hew Notes