Skip to main content

Abstract type parameters with value witnesses, and a receive ABI that can borrow

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

Until now, a generic function couldn’t reach the back end without being told what T actually is. Monomorphisation handled that: pick a concrete instantiation, stamp out a specialized copy, lower that. It works, but it has a precondition baked in — there must be a concrete type before lowering can begin. A generic body with no concrete instantiation in sight had nothing to lower to.

That precondition is what I spent the first half of today removing.

A type parameter is now a real type

The piece that was missing is a representation for an abstract type parameter — one that survives all the way into MIR instead of being a placeholder the pipeline insists on resolving first. So MIR’s resolved-type enum gets a new structural variant for exactly that: a T that is genuinely just T, the single authority for an un-monomorphised type.

The conversion from the checker’s types into resolved types is total and lossless in both directions, but it only ever produces the abstract variant when it’s working inside a scope that actually declares the parameter. The unscoped path can’t produce it at all. That’s deliberate — it means every existing program, none of which carry abstract parameters into lowering, is completely unaffected. The new variant only shows up where a real type parameter is in scope.

Here’s the shape of what now lowers without a concrete type:

type Pair<T> {
    first: T;
    second: T;
}
 
fn swap<T>(p: Pair<T>) -> Pair<T> {
    Pair { first: p.second, second: p.first }
}

Nothing in swap needs to know what T is to be lowered into MIR. It’s lowered once, abstractly.

Clone, drop, move — through witnesses

But “lowered abstractly” raises the obvious question: how do you generate code that clones, drops, or moves a value when you don’t know its type? You can’t emit the type-specific copy or destructor inline, because there isn’t one yet.

The answer is to not emit those operations as concrete code at all. There are now four first-class MIR instructions that stand in for the type-dependent operations a generic body needs: size-of, align-of, drop-glue, and move. Each one carries a resolved-type operand. When that operand is an abstract T, the instruction is a witness — a promise that says “the size of T goes here,” or “run T’s drop glue on this value,” to be filled in against the value-witness table — the per-type record of size, alignment, and drop/move glue — once a concrete type is known.

A generic body, then, lowers into a separate polymorphic bucket on the pipeline — lowered once against an abstract substitution, kept entirely out of the set of bodies bound for codegen. The monomorphic pipeline still drives everything that actually gets emitted. The polymorphic bodies are the substrate; monomorphisation will consume them later by resolving each witness against a concrete type.

The substitution recurses, which is the part I cared most about getting right. An abstract T inside a borrow, inside a trait object, inside a tuple or an array or a function type — the traversal walks all of those composite shapes and finds the parameter wherever it’s nested. A witness operand that’s (&T, Vec<T>) resolves both Ts.

And it fails closed. The MIR verifier re-checks that every witness operand naming a type parameter names one that’s actually declared on the enclosing function. An out-of-scope abstract type, or worse, an unresolved inference variable, reaching a witness operand is a lowering bug, and the verifier rejects it rather than letting it slide toward the emitter. Codegen itself has the same guard on both ends — an abstract type parameter or a witness instruction arriving at the LLVM emitter is a hard failure, not a guess. The witnesses are real instructions in the IR, but no concrete code can be emitted from one until it’s been resolved. (I’d rather the compiler refuse than fabricate a size.)

The receive side learns to borrow

The second half of the day was a different ownership problem entirely, on the actor path.

When an actor receives a message, the obvious model is: the message is moved into the handler, the handler owns it, the handler drops it when it’s done. That’s the copy-mode path, and it’s fine for small messages. But it means a large payload — a multi-kilobyte record, say — gets copied into the handler even when the handler only reads a field or two and never holds onto it.

What I want instead is for the handler to take a borrow of the message — a non-owning view of the buffer the envelope already owns — and only pay for a real owner if the value actually escapes the handler.

Here’s the kind of handler this is for:

record Report {
    id: i64,
    body: string
}
 
actor Sink {
    var count: i64;
 
    receive fn submit(r: Report) {
        count = count + 1;
    }
 
    receive fn total() -> i64 {
        count
    }
}

submit receives a whole Report and touches none of its owned contents — it just bumps a counter. There’s no reason to copy body into the handler for that. (Update, June 2026: that field originally read let count — v0.5.0 now enforces let-field immutability, so a counter you bump has to be var. The snippet above is corrected to compile today.)

So the dispatch trampoline gained a borrow-mode arm. It resolves the payload through the envelope’s payload pointer and loads it by value, guarded by a hard null check before the load rather than dereferencing something that might not be there. When a received value is a borrowed view, the handler emits no drop for it; the one and only final release stays with the envelope. The handler is reading through a window into someone else’s buffer, and it knows not to free what it’s looking at. One status note before going further: this arm is built but not yet routed — every call site still passes copy mode. More on that below.

The interesting case is escape. If a borrowed view does leave the handler — stored into an actor field, returned, re-sent to another actor, or folded into an owned aggregate like an enum variant or a record field — then the sink it escapes into takes its own retained owner through the string refcount path. Now there are two distinct owners: the envelope’s original and the escapee’s retained clone. The envelope release and the sink’s drop each free their own, exactly once, and the shared buffer is freed exactly once overall.

What makes that safe rather than hopeful is that the move-destination classifier is exhaustive. Every place a value can land is accounted for. Any sink that isn’t in the retained set is rejected under a live borrow rather than silently copying the borrowed handle and creating a second owner nobody tracked. There’s no “probably fine” default; an unclassified destination is a compile-time stop.

Carrying the alias mode, fail-closed by default

The receive ABI is one half of a pair. The send side has to agree on whether a given send is a plain copy or an aliased hand-off, and that decision is the type checker’s — it classifies each actor send’s aliasing. The send terminator in the IR now carries that classification as a discriminant, stamped at the moment the terminator is constructed, with Copy as the default.

The default matters more than it looks. A send terminator is promoted to alias mode only on an explicit alias classification. Every copy classification stays copy, and — this is the part that’s load-bearing — every absent classification also stays copy. If the checker’s facts don’t reach the lowering for some reason, the result is the safe, owning copy, never the aliased borrow. The lossy direction is closed.

And the aliased dispatch boundary itself is currently a hard panic before any destructor-bearing payload could reach the owned-value handler. That’s not a permanent fixture — it’s a guard rail that stays up until the borrow-only receive ABI is fully wired through, so a compiled program can’t double-drop an aliased payload in the window where the send side knows about aliasing but the receive side doesn’t yet honour it. Every exit of the send state machine — null mailbox, allocation failure, a closed or bounded-reject mailbox, the blocking and coalesce fallbacks, a successful enqueue, a drop-fault discard — releases the payload exactly once.

The two halves are built but not yet joined end to end. The send side stamps the discriminant; the receive side knows how to borrow; the call sites all still pass copy mode, and the native scheduler fails closed on envelope nodes. Routing and scheduler activation are the next piece. (Update, June 2026: still the next piece — it didn’t land in v0.5.0. The alias mode is stamped in the IR but deliberately unconsumed by codegen, reserved for the retain-on-share send work.) I’d rather land the two halves separately, each safe on its own, than flip the whole path live in one motion and go hunting for the double-free afterward.

The control-flow pieces I’d been deferring

Alongside the ownership work, a few smaller things landed that the language frankly should have had already.

Top-level const declarations were being dropped on the floor — the HIR lowering had a catch-all that swallowed them, which cascaded into not-yet-implemented and unresolved-symbol errors the moment you referenced one. They now lower properly, through a dedicated const item, to LLVM globals:

const MAX: i32 = 1024;
 
fn clamp(n: i32) -> i32 {
    if n > MAX {
        MAX
    } else {
        n
    }
}

Initializers are folded through the one shared const-evaluation engine — the same one used everywhere else, not a private second fold — so there’s a single authority for what a constant evaluates to. Integer and string literals lower to typed const values. Anything the shared evaluator doesn’t accept — bitwise ops, wrapping arithmetic, negatives, references to other consts — is rejected with an actionable diagnostic rather than quietly admitting a half-folded value. That last constraint is deliberately tighter than I’d eventually like, but tight-and-honest beats loose-and-guessing. (Update, June 2026: negatives fold now; bitwise ops, wrapping arithmetic, and const references are still rejected.)

And control flow rounds out: a bare loop, with break and continue as the two ways out of it.

fn count_evens(limit: i64) -> i64 {
    var i = 0;
    var seen = 0;
    loop {
        if i >= limit {
            break;
        }
        i = i + 1;
        if i % 2 == 1 {
            continue;
        }
        seen = seen + 1;
    }
    seen
}

Recursive value types — a record that contains itself by value, with no indirection — are now rejected at check time instead of being discovered as an infinite layout somewhere downhill. An infinitely-sized type has no honest layout, so the right moment to say so is the moment you can.

None of those three are clever. They’re the kind of thing you only notice is missing when you reach for it and it isn’t there, write a one-line program, and get an error that has nothing to do with what you wrote. That’s most of what today was, really — the abstract-generics and borrowing-receive work is the substrate the next stretch needs, and the const-and-loop work is paying down the small debts that were making simple programs lie about why they failed.

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