Skip to main content

Closing the Gaps: std Imports Compile Native, HashMap Indexing, and Idiomatic Examples

· 8 min read

The whole arc this week was about one question: can a real program lean on the v0.5 surface yet? Not a hand-picked fixture that dances around the rough edges — a program that imports a standard-library module, counts things in a map, defines a trait, and gets a native binary out the other end.

The answer turned out to be “almost, and here’s the list of why-nots.” Most of them were boring once I found them. A couple were not.

import std.fs reached the codegen emitter as an unresolved type

If you imported std.fs and called anything, the compiler got most of the way through and then stopped with this:

E_NOT_YET_IMPLEMENTED: fail-closed: D10 violation: Named/user type `fs.IoError`
reached the LLVM emitter; the MIR D10 gate should have rejected this earlier

That diagnostic is the compiler doing the right thing. fs.IoError is the error enum every file-system function returns inside a Result, and it arrived at the LLVM emitter without a layout — so the emitter refused to guess. The interesting part is why it had no layout, because there were four separate reasons stacked on top of each other, and each one only became visible after I fixed the one above it.

The first was a one-character scoping bug. A module-qualified zero-arg enum like fs.IoError is looked up by its short name (IoError) when the qualified form misses — but that fallback was sitting inside the branch that only runs for types with arguments. A zero-arg enum never took that branch, so it never got the short-name lookup, so it fell straight through to the fail-closed arm.

The second was subtler and the kind of thing that produces wrong output rather than a clean error if you’re unlucky. At the import boundary the MIR carries the qualified name — Result<Listener, fs.IoError> — while the side that registers layouts uses the bare name, Result<Listener, IoError>. Without normalizing one to the other, the compiler built two distinct LLVM structs for what is the same type, and then moves across the boundary failed because the types didn’t match themselves.

The third: Result and Option are seeded into the lowering context as built-in generics, but they’re never emitted as ordinary type declarations. The pass that walks function bodies looking for concrete instantiations to register only built its map from declared types — so when it found Result<Listener, IoError> in a monomorphic body, it silently dropped it. Nothing to register it against.

The fourth I’ll come back to, because it’s the one that wasn’t boring.

With the first three closed, the plain surface compiles and runs natively:

import std.fs;
 
fn main() {
    fs.write(".cache/hello.txt", "hello from hew\n");
    let body = fs.read(".cache/hello.txt");
    print(body);
}
hello from hew

fs.exists, fs.read, fs.write — the ones that return a plain value rather than a Result — go end to end now. The try_read family that returns Result<string, IoError> still trips the emitter on the IoError enum itself: the layout registration is fixed, but the enum’s codegen path through a matched Result is the next gap, not this one. So the honest state is that import std.fs compiles — the type and layout layers are no longer the wall — and the plain read/write/exists surface runs. The structured-error surface is one emitter fix away, and I know exactly where. (I’d rather ship that sentence than a demo that only works if you never touch an error path.)

Update, June 2026: that emitter fix landed before this posted — try_read now compiles and runs natively through both the Ok and Err arms.

The opaque-handle and Vec-element fixes underneath

std.encoding.json had its own version of the same disease, in two parts.

A JSON value is an opaque handle — json.Value, a pointer the runtime owns and Hew code passes around without looking inside. When that handle crossed the module boundary it reached the emitter under its qualified name json.Value, but the set of “these are opaque pointers” was keyed by the bare decl name Value. Same short-name mismatch, different table. And the impl methods on that handle — the fluent builders like .with_string(...) — were being dropped entirely, because imported impl methods were gated on a per-method pub marker that impl methods simply don’t carry. Across the boundary they showed up as unsupported indirect calls.

Once both were fixed, a round-trip through the module boundary works:

import std.encoding.json;
 
fn main() {
    let obj = json.object()
        .with_string("name", "Hew")
        .with_int("version", 1);
 
    let s = obj.stringify();
    println(s);
 
    let parsed = json.parse(s);
    let field = parsed.get_field("version");
    println(f"version={field.get_int()}");
 
    field.free();
    parsed.free();
    obj.free();
}
{"name":"Hew","version":1}
version=1

The fourth std::fs reason from earlier lives in the same neighbourhood. Vec<string>, Stream<bytes>, Sink<bytes> — these are pointer-backed handles whose type argument is a phantom that describes the element type, not inline storage. The ownership classifier was recursing into that argument and concluding Stream<bytes> owned heap inline, which blocked Connection::into_stream_sink from returning its (Stream<bytes>, Sink<bytes>) pair through the composite-return gate. The fix is to recognize the known set of opaque pointer-backed handles and not recurse into their phantom arguments. Routing a Vec of pointer-shaped handles through the right element ABI was the same realization applied one layer over.

m[k] on a HashMap

The language guide says, flatly, “Maps have no subscript — always use .get.” Until now that was true: you wrote match m.get(k) to read and m.insert(k, v) to write, and that was the only way.

That’s fine, but it’s a paper cut on the most common collection operation there is — counting. Here’s the count-the-faces loop with indexing:

fn main() {
    let rolls = [1, 6, 6, 3, 6, 1];
    var counts: HashMap<i64, i64> = HashMap.new();
 
    for face in rolls {
        match counts[face] {
            .Some(n) => {
                counts[face] = n + 1;
            },
            .None => {
                counts[face] = 1;
            },
        }
    }
 
    match counts[6] {
        .Some(n) => println(f"sixes: {n}"),
        .None => println("sixes: 0"),
    }
}
sixes: 3

The thing I wanted to keep honest here is the read side. m[k] does not return a bare V that traps on a missing key — it returns Option<V>, and a missing key is None. You still have to match it. The subscript is sugar over the existing get/insert ABI, not a new way to crash: the read desugars to the same get that yields an Option, and m[k] = v desugars to insert. The key is bound by the same K: Hash + Eq contract it always was, and the Index trait that Vec uses for its bounds-checked element access is untouched — this is a separate path that happens to share a spelling. (I went back and forth on whether the read should panic on a miss like some languages do. Fail-closed won: the type makes you handle the absence, the same as .get always did.)

By-value generic methods, and trait bounds on primitives

Two type-system gaps that only show up once you write generic code that does real work.

The first: a generic impl method with a by-value receiver didn’t have a body to call. If you had impl<T> Pair<T> { fn first(self) -> T } and a concrete Pair<i64>, then p.first() lowered to a call whose mangled target pointed at nothing — the monomorphization that produces a concrete body was registered for the mutable-receiver case but never for the by-value one. So p.first() type-checked and then linked against a function that didn’t exist. The fix registers the monomorphization from the by-value path too, reusing the same receiver-type-driven machinery the mutable path already used.

The second is the one I’m happiest about, because it makes the trait system feel like it means what it says. A bound like T: Summable on a primitive argument was always rejected — even when you’d written an explicit impl Summable for i64. The primitive arm of bound-checking only ever consulted the table of built-in marker traits (Ord, Clone), and never looked at your own impls. So this was an error before, and runs now:

trait Summable {
    fn summary(val: Self) -> string;
}
 
impl Summable for i64 {
    fn summary(val: i64) -> string {
        f"i64({val})"
    }
}
 
fn describe<T: Summable>(x: T) -> string {
    x.summary()
}
 
fn main() {
    println(describe(42));
}
i64(42)

The fix consults your impl registry first — keyed by the canonical lowering name, with integer and float literals defaulting to i64 and f64 — and only then falls back to the marker table so the built-in markers keep working. A primitive with no matching impl still fails the bound. i64 satisfies Summable because you proved it does, not because the checker gave up and waved it through.

Driving the failure detector

One more from the same week that isn’t stdlib but is the same disease — machinery that exists and is never asked. The cluster layer had a SWIM failure detector that was trained but never consulted. The phi-accrual estimator — the thing that decides whether a silent peer is dead or just slow — was being fed observations, but hew_cluster_tick had no call site. Nothing ever asked it for a verdict.

So I wired a per-node ticker into the node lifecycle. Each protocol period it probes a member, consults phi-accrual, escalates SUSPECT to DEAD on timeout, runs k-member indirect probes before believing a death, handles incarnation self-refutation when a “dead” node protests that it’s alive, and piggybacks gossip over the existing connection mesh. The inbound control path trusts nothing it can’t verify: a cross-attribution guard so one peer can’t speak for another, a bounded codec, and an allowlist on the gossip it’ll accept.

The two facts I actually cared about proving: a killed node is detected — it goes DEAD and a PartitionDetected fires — and a slow-but-alive node is not falsely killed. The second is the hard one. A failure detector that kills everything is trivially correct about dead nodes and useless. The ticker thread is lifetime-bounded by a join before any cluster teardown on every stop path, so the detector can’t outlive the thing it’s detecting on.

The examples had to model the language

The last piece isn’t a feature, it’s a debt. The shipped tutorials had drifted — some of them used spellings that no longer compiled, and there was no gate stopping that from happening again. So the tutorials got repaired against a regression check that actually runs them, and there’s now a language guide that was written the opposite way from how documentation usually gets written: every example in it was executed against the compiler before it went in. Not “this should work” — “I ran this and it printed Bob.”

That’s why the HashMap section of the guide still says “Maps have no subscript.” It wasn’t wrong when it was written. The examples are supposed to be the truth about what idiomatic Hew looks like today, which means they’re only as good as the last time someone made them run — so now something makes them run, every time.

Update, June 2026: honest footnote — the gate runs the code, not the prose around it, and those subscript sentences are still sitting in the guide as I post this. They’re queued for the same fix the tutorials got.

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