The Int That Stayed 32 Bits

Adding a feature is visible work. Deciding not to add one — and being able to show why — is the harder half of language design.

merelanguage-designessaywasm

Mere compiles to four backends. Three of them — the interpreter, the C backend, the LLVM backend — treat an integer as 64 bits. The fourth, WebAssembly, treats it as 32. The obvious next task writes itself: widen the Wasm int to 64 and make the four agree. I spent a session on it and shipped nothing, because the interesting work turned out to be deciding not to. This is a note about how that decision gets made — not the conclusion so much as the method that produced it.

Start from the contract, not the mismatch

The first instinct on seeing “three backends say 64, one says 32” is to call the 32 a bug. It isn’t. Mere never promised a uniform integer width. The language reference has always said it plainly: the C and LLVM backends use 64-bit integers, the interpreter uses the host’s native int (usually 63 bits), and Wasm uses 32. An integer literal that doesn’t fit 32 bits is a compile-time error on Wasm — with a message that names the range, points at the wider backends, and links the docs — not a silent truncation.

So “Wasm is 32 bits” is not a broken promise. It is the promise. The first move in declining a feature is to re-read what you actually committed to, because a surprising amount of “we should fix this” dissolves into “this is the documented envelope working as intended.” The only real gap is a computation that overflows silently — and every backend does that at its own width, the same way C and its descendants have for fifty years.

The asymmetry that looks like a contradiction

Here is the objection that has to be answered honestly: floats on Wasm are already 64 bits. A float is boxed — the value is a pointer to eight bytes of f64 in linear memory — and the arithmetic unboxes, computes with native f64 instructions, and re-boxes. If we went to that trouble for float, why not for int?

Because the two are not symmetric, and the way to see it is to ask what happens if you don’t box each one.

Leave an int at 32 bits and you get the same meaning in a narrower range. Almost every program is unaffected; the edge is rare and, for literals, loud. Leave a float at 32 bits and the rounding changes on nearly every computation — 0.1 isn’t even representable the same way — so cross-backend results diverge silently, everywhere. Float had no “don’t box” option that preserved parity; int does. Same mechanism, opposite verdict, because the failure modes are different. A general rule hides in there: the same machinery can be right for one type and wrong for another, and the deciding question is not “is it consistent?” but “what breaks if we leave it alone?”

Design the alternative all the way down

It would have been easy to stop at “box the int like the float — it’s the only consistent way.” I nearly wrote that sentence. It’s wrong, and the thing that showed it was wrong was finishing the design instead of stopping at the first plausible one.

There are two ways to give Wasm a 64-bit int. Box it, like float. Or widen every value slot in the object model from four bytes to eight and store ints unboxed. Box-int is small and pleasingly symmetric with float — and it quietly ruins the one thing this backend exists for. Wasm’s allocator is a bump pointer that never frees within a run, and the backend’s reason to live is the browser: long-running pages, a playground, a game that does integer arithmetic on every keystroke. Box every int and each of those allocates eight bytes that never come back. A correctness fix for a niche — bignum arithmetic inside a browser tab — that leaks the primary use case is a net negative. The other option, uniform 8-byte slots, is genuinely correct and touches something like a thousand sites in the code generator plus the entire JavaScript host, which would have to move to BigInt everywhere, channels and all — an enormous rewrite for zero present demand.

The alternative I almost called “the only way” was the worse of the two. Designing it to the end is what turned the decision from a shrug into a judgment.

Write the receipt

Declining a feature is only honest if you also record when you’d accept it. The decision is written down with its trigger — the first real dogfood that needs 64-bit integers in the browser — and with the design to reach for when that day comes: box-int if the motive is one-shot correctness, uniform-i64 if it’s long-running or performance, each with its prerequisite named. A “no” with a documented threshold isn’t a refusal to grow. It’s a smaller, sharper commitment than a hasty “yes” would have been.

The discipline

Shipping a feature is legible. There’s a diff, a version bump, a line in the changelog. Declining one looks, from the outside, like a day where nothing happened. But a language is defined at least as much by what it refused as by what it absorbed — every accepted feature is weight the next reader carries forever. The int that stayed 32 bits is not a gap waiting to be filled. It’s a decision, with a contract behind it, an asymmetry that justifies it, a rejected alternative measured against the backend’s real purpose, and a receipt naming the day it could change. That is the shape of a good no.

Postscript: the receipt came due

A day later the trigger arrived — not stumbled upon so much as met halfway, but by an ordinary program either way. A small date/time utility, mdate: hand it a Unix epoch in milliseconds and it returns a UTC timestamp, all integer arithmetic. On the interpreter and the native backend it matches the reference byte for byte. Turn it into a clock in the browser and it doesn’t print the wrong time — it traps: today’s epoch-ms, about 1.75×10¹², overflows 32 bits and the float-to-int step refuses it.

That is the receipt’s condition, met exactly: a real dogfood — one you’d write regardless — that needs 64-bit integers in the browser, and a long-running one (a clock, not a one-shot). So the design is the one already named: uniform 64-bit slots, not boxing.

The earlier decision wasn’t wrong. It bought a month of not carrying that weight against zero demand, and it made this moment a two-line lookup instead of a fresh argument. That is the whole point of the receipt: a good no isn’t permanent, it’s a decision with an expiry condition — and the discipline is only finished when you honor the condition on the day it fires.

← Back to Notes