For a while Hew has had numbers that were honest about being 64-bit and vague about everything else. You could write i32 and the type checker would nod and quietly treat it as i64. That is the kind of convenience that feels fine until someone serializes a value across a wire boundary, or indexes into something, or does arithmetic near a width limit, and discovers the language was rounding off the part of the type that mattered. Today I closed that gap, and two others next to it: records are real end-to-end, and the failure floor — what happens when arithmetic or indexing goes wrong — finally lands instead of being undefined behaviour I was hoping nobody would hit.
Integers with real widths
The first change is that all eight explicit integer widths and both floats are first-class types now: i8, i16, i32, i64, u8, u16, u32, u64, f32, f64. Everything says what it means; the lone shorthand is byte for u8. There is deliberately no int and no uint — write one and the compiler rejects it and points you at i64 or isize, because “integer” is not a width.
The part I care about more is what stopped being allowed. Each integer width is its own opaque type at the unifier level. Mixing them in an expression is a type error, not a silent promotion:
let a: i32 = 1;
let b: i64 = 2;
let c = a + b; // error: cannot implicitly convert `i32` to `i64`; use an explicit `as i64` castThis is deliberately annoying. C-family implicit integer promotion is a steady source of bugs that compile cleanly and corrupt data at the edges, and the convenience is never worth what it costs at a wire boundary. If you want widths to meet, you say so with as, and the cost is visible at the call site where the conversion actually happens.
There is also a pair of types I had been putting off: isize and usize. These are platform-sized — and, importantly, they are not aliases for i64/u64. They are distinct types that lower to 32-bit on a WASM32 target and 64-bit on a native 64-bit target. That distinction is the whole point: a value that is “as wide as a pointer on this machine” is a different fact than “exactly 64 bits”, and conflating them is exactly the bug that bites you when you compile the same program for two targets. The width selection happens in the LLVM lowering path, and usize correctly reports as unsigned to the rest of the type system.
(Update, June 2026: isize and usize shipped in v0.5.0 as reserved names — the compiler recognises them and produces diagnostics pointing at the explicit-width types — but their arithmetic surface is not yet usable; you cannot declare or use a variable of type isize/usize. Use i64/u64 plus explicit target-width reasoning until the pointer-sized integer surface lands.)
For the conversions themselves, the thing that works today is as: a as i64 compiles and runs, and the mixed-width error above points you straight at it. On top of that sits a designed method surface. Widening is infallible and gets the plain name — .to_i64() on an i32 source, same sign, strictly wider. Anything that can lose information — narrowing, crossing signedness, or coming from a platform-sized source — goes through .try_to_<W>(), which returns a Result, because that conversion can genuinely fail and the type should say so. The checker admits both families as I write this, but neither one lowers yet — calling either stops at HIR with a not-yet-implemented diagnostic. (Update, June 2026: that is where they stayed through v0.5 — as is still the conversion that actually executes.)
Records, end to end
The second piece is records. A record is a value type — a named bag of fields with no identity, no lifecycle, no close-contract. The declaration now goes all the way down: construction, field access, functional update, and the lowering through HIR and MIR behind all of it.
record Point {
x: i64,
y: i64
}
fn main() -> i64 {
let origin = Point { x: 0, y: 0 };
let shifted = Point { x: 10, ..origin }; // functional update
let seen: Vec<Point> = Vec.new();
seen.push(shifted);
let here: i64 = match seen.contains(shifted) { // uses the derived Eq
true => 1,
false => 0
};
shifted.x + shifted.y + here
}A couple of things in that snippet are doing more work than they look like they’re doing.
The functional-update form Point { x: 10, ..origin } builds a new record from an existing one with some fields overridden. The ..base spread has to be the last item in the field list — the explicit overrides come first, then the base fills in the rest. I tried writing it the other way around first and the parser was right to stop me: “functional-update ..base must be the last item in the field list.” Putting the base last reads as “start from these overrides, then take everything else from origin”, which matches how the value is actually constructed.
The other quiet thing is seen.contains(shifted). That call needs Point to be comparable for equality, and I never wrote an Eq impl. Markers like Eq, Hash, Send, Sync, Frozen, Clone, and Copy are auto-derived from the field types. Point has two i64 fields, and i64 is all of those things, so Point is too. The derivation is purely field-driven, with one carve-out: a record is never a Resource, even if it wraps a field that is, because a record is not itself an OS handle with a close-contract — it is just a value that happens to contain one. That rule keeps the value/resource distinction clean instead of letting it leak upward through composition.
(Update, June 2026: the structural Eq/Hash derivation described here did not make it into v0.5.0 — using == on a record or passing one to contains is refused at the checker with a diagnostic. The auto-derivation framework and the marker rules are correct; the part that isn’t wired yet is the codegen for structural equality comparison. The seen.contains(shifted) example above works for Vec of scalar types; for a record key you would use a HashMap with an explicit equality function or wait for v0.5.1.)
The failure floor
This is the part I think matters most, and it is the least glamorous. The question it answers is: what happens when arithmetic overflows, or you divide by zero, or you index off the end of a Vec?
The old answer was “undefined, please don’t.” The new answer is: it traps. Loudly, with a specific reason.
The default arithmetic operators trap on overflow. big + 1 where big is i32::MAX does not wrap, does not saturate, does not produce a garbage value you carry around until it ruins something downstream. It stops. If you want one of the other behaviours, you ask for it by name, and you get three distinct families that answer overflow three different ways:
fn report(label: i32, value: i32) {
// ... print label/value ...
}
fn main() -> i64 {
let big: i32 = 2147483647;
// big + 1 overflows i32. Each family answers differently:
let wrapped: i32 = big.wrapping_add(1); // -2147483648 — wraps
let clamped: i32 = big.saturating_add(1); // 2147483647 — pinned to MAX
let checked: Option<i32> = big.checked_add(1); // None — overflow refused
report(0, wrapped);
report(1, clamped);
// A default `big + 1` never reaches here: it traps.
match checked {
.Some(_) => 1,
.None => 0
}
}wrapping_* gives you modular arithmetic and never traps. saturating_* clamps to the type’s MAX or MIN. checked_* returns an Option so you can branch on whether it overflowed. The point is that each of these is an opt-out, named at the use site. The safe-but-trapping behaviour is the default; the other behaviours are things you reach for on purpose, in code that should obviously be doing something unusual with overflow.
Indexing and division get the same treatment. A divide-by-zero traps. An out-of-bounds index traps. A range slice with bounds past the end traps. None of these read past memory or hand back a value that lies about what happened:
fn divide(n: i64, d: i64) -> i64 {
n / d // d == 0 traps: DivideByZero (supervisor exit code 202)
}
fn first(xs: Vec<i64>) -> i64 {
xs[0] // empty xs traps: IndexOutOfBounds (supervisor exit code 205)
}
fn main() -> i64 {
let xs: Vec<i64> = Vec.new();
xs.push(10);
divide(first(xs), 2) // first(xs) = 10, 10 / 2 = 5
}What “trap” actually does
This is where the engineering gets specific, and where I want to be honest about the seam rather than wave at it.
Each trap carries a kind, and each kind has a stable exit code: integer overflow is 201, divide-by-zero is 202, the signed MIN / -1 overflow case is 203, an out-of-range shift is 204, an out-of-bounds index is 205. (These sit just above the heap-exceeded code, 200, that was already there.) When a trap fires inside an actor’s dispatch, it longjmps back to the scheduler with that code stored as the actor’s crash reason, so the supervisor sees ExitReason::IntegerOverflow rather than a generic hardware signal. That is the case that matters: a misbehaving actor crashes with a reason the supervision tree can actually act on, and its teardown runs like any other crash.
Outside a dispatch context — top-level main, the eval REPL — there is no scheduler frame to longjmp back to. There the trap falls through to LLVM’s llvm.trap and the process aborts with a hardware signal, which is the same thing that happened before. So if you run one of the trapping snippets above directly as a program, the process just dies; the named exit codes are what a supervisor observes when the trap happens under an actor. I would rather say that plainly than imply every trap surfaces a tidy code everywhere. It does not yet. The actor path is the one that carries the reason today; the top-level path carries an abort.
The reason this routes through a longjmp seam at all is that it reuses the exact mechanism the heap-cap exit already used. There was a working crash-recovery path; the trap kinds slot into it instead of inventing a parallel one. Keeping the trap-code constants in lock-step between the codegen side and the runtime supervisor is a real obligation — they are two copies of the same table — and it is written down in both places so the next person to add a trap kind adds it in both.
The escape hatch that does not escape yet
Records, widths, and traps all push in the same direction: the language refuses to silently do the wrong thing. The counterweight to that is unsafe, which is where you eventually tell the compiler “I know more than you do here, let me out.”
The keyword landed today. What it does today is almost nothing, and I want to be precise about that:
fn main() -> i64 {
let xs: Vec<i64> = Vec.new();
xs.push(10);
xs.push(20);
xs.push(30);
// The keyword is here; the ops that read it are not. Today the
// slice inside `unsafe { }` is bounds-checked exactly like one
// outside it. The scope is reserved for the raw-pointer and FFI
// operations that will eventually opt out.
let middle: Vec<i64> = unsafe {
xs[1..3]
};
middle.len()
}unsafe { } parses as an expression form, the checker tracks whether it is inside one, and… that is it for now. No operation reads the flag yet. A slice inside an unsafe block is bounds-checked exactly like a slice outside it. There is no unsafe fn surface — the only form is the block. Nesting parses silently. A bare unsafe foo() without a block is a parse error that points you at the missing brace.
This is infrastructure, deliberately built ahead of its consumers. The raw-pointer operations and the FFI surface that will actually need to opt out of safety checks are the intended consumers — they are the things that will read the flag the checker is now tracking. Building the scope first means those operations have somewhere to live the moment they exist, and it means the boundary between “safe by default” and “explicitly not” is a real, checked thing in the language rather than a convention. The default stays safe; the escape is a named, scoped, visible decision. (Update, June 2026: raw pointers and the C-string helpers stayed v0.6+ work — a raw-pointer dereference is rejected even inside the block, because there is no lowering for it yet. But the flag did pick up its first reader before v0.5 shipped: calling an extern “C” function outside an unsafe { } block is now a checker error, and inside one it compiles and runs. Everything else in an unsafe block is still checked exactly like code outside it — today the block gates FFI calls; it does not switch off any check.)
Why all of this on the same day
These three pieces look unrelated — integer widths, record lowering, a trap terminator — but they are the same commitment from three angles: be honest about what a value is, and fail where the system can see it. The value layer was the soft spot under all of that, and today it stopped being soft.