There is no garbage collector in Hew, and there is no malloc/free for you to get wrong. Every allocation has exactly one owner, the compiler inserts the free at the point that owner goes out of scope, and if you write code that would touch a value after it moved away, the program does not compile. That is the whole memory model in one sentence, and the rest of this post is what it costs to make that sentence true.
I care about this property more than almost anything else in the language, because it is what lets one mid-level IR — I call it MIR — decide everything about memory once and carry the decision straight to the backend. The codegen step doesn’t reason about lifetimes; it transcribes a decision that was already made. When that boundary is clean, the backend is small and a single program shape lowers to native and WASM the same way. When it leaks — when codegen has to re-derive who owns what — that’s exactly where the double-frees live.
Single ownership, drops on every exit
Take a binding to a heap value and try to use it from two places:
// Intentional compile error — illustrates E_MIR_CHECK: UseAfterConsume
fn main() -> i64 {
let original = "field notes";
let alias = original; // original moves into alias
original.len() // error: original was consumed
}That doesn’t compile. The checker reports binding 'original' is used after it was consumed, points at the move, and stops. A string is an owned heap value, so binding it elsewhere moves it — there is now exactly one owner named alias, and the name original is dead. This is the same discipline Rust made famous, minus mutable borrows for now (more on that below).
The payoff is that drops are mechanical. The owner of alias goes out of scope at the closing brace of main, so that’s where the string gets freed — once, by the one owner, on every path that reaches the end of the scope including early returns. I don’t write the free, and I can’t forget it.
Where this gets interesting is that the drop decision is computed in MIR, not in codegen. After the move/init/aliasing checks run, an elaboration pass walks the function and emits explicit Drop instructions — this binding, this type, freed here. You can dump it:
Drop {
binding: BindingId(1),
name: "u",
ty: Named { name: "User", args: [], builtin: None },
},That Drop is the decision. Codegen reads it and emits a call; it does not get a vote on whether the binding is an owner or a borrow, because the elaborator already answered that and borrows simply have no Drop entry. I belabour this because the previous backend got it wrong by re-deriving ownership at the bottom of the pipeline, and the bug that produced — a string freed by two owners because two halves of the compiler disagreed about which one owned it — is precisely what re-deriving buys you. Decide once, carry the decision. The backend stays dumb on purpose.
Three value classes
Not everything is heap-owned, so “drop on scope exit” needs to know what kind of value it’s looking at. There are three.
BitCopy values are the scalars — i64, f64, bool, and the like. They have no heap behind them, so a move is a bit copy and a drop is nothing. Binding one to two names is fine because there’s nothing to own.
Owned aggregates are records, enums, vectors, and string. These carry heap, so they move (one owner) and they drop (the owner frees). A record’s drop is recursive and tag-aware for its fields: an enum field drops only the payload of its active variant, walked by a per-enum helper that dispatches on the tag and reverses field order on the way out. Here’s an owned record that crosses a function boundary and gets dropped with no GC anywhere in sight:
record User {
name: string,
id: i64,
}
fn label(id: i64) -> User {
User { name: "ada", id: id }
}
fn main() -> i64 {
let u = label(7); // u owns the record and its "ada" string buffer
u.id // u dropped at end of scope: free the string, then the record
}That runs and exits 7. The User is built inside label, ownership moves out on the return, and the elaborated MIR puts a single Drop on u at the end of main. The drop frees the name string first, then the record shell. The id field is BitCopy, so it contributes nothing to the drop. There is no finalizer thread, no mark-and-sweep, no reference graph — just a free at a statically known point.
For enums specifically, the clone and drop helpers are generated from a single classification of the enum’s layout, so the two paths can’t drift — the thing that clones a variant payload and the thing that frees it agree by construction. The generated drop helper switches on the tag, frees the active payload, and if it ever sees a tag outside the declared range it traps rather than indexing off the end of the variant table. That last part is a fail-closed backstop: the type checker already proves the tag is in range for well-typed programs, and the trap is there to prove it at runtime if a producer regression ever slips an impossible tag through. (When I drafted this, the generic-enum-state path — an actor field typed Option<string> initialized at spawn time — was wired and verified at the IR level but not yet runnable end-to-end. Update, June 2026: it runs now. Default-initialized non-scalar actor state works end-to-end, and passing a non-Send value like Option<string> as a spawn argument is rejected with a typed “not Send” diagnostic — fail-closed, not zero-initialized.)
#[opaque] handles are the third class. When a type is a handle to something the runtime owns — a socket, a semaphore, a process — the body is empty and the attribute says “this is a raw pointer, not a Hew aggregate”:
#[opaque]
pub type Connection {
}That lowers to a raw pointer. Hew doesn’t try to drop it field-by-field, because there are no fields it knows about; the owning subsystem provides the cleanup. Opaque handles are how the standard library models TCP connections, semaphores, deques, and file descriptors without lying to the ownership model about what’s behind the pointer.
string is copy-on-write
A naive ownership model makes strings expensive. If every move is fine but every share requires a deep copy, you copy constantly — pass a name to a function, copy; stash it in two records, copy twice. So string is a refcounted copy-on-write value, and that is the most intricate single piece of the runtime.
It sits on a header-aware C-string allocator. Every string allocation is laid out as a 16-byte header followed by the NUL-terminated bytes, and the pointer Hew hands around points past the header at the data:
[ magic:u64 | rc:AtomicU32 | reserved:u32 ] "field notes\0"
^ the pointer callers seeThe magic is a fail-loud sentinel. free recovers the header by subtracting the fixed offset, checks the magic before it frees anything, and aborts if the magic is wrong rather than calling free on a bad base pointer and corrupting the heap. On the way out it stamps a poison value over the magic, so an immediate double-free through the same path is more likely to trip the check on the second call. (It is best-effort, not a guarantee — the poison write is immediately followed by the real free, so a much later double-free reads reclaimed memory. With correct refcount pairing the count only hits zero once, so the poison is a debugging aid, not the safety mechanism.)
The rc field is what makes sharing cheap. Cloning a string doesn’t copy the bytes — it bumps the refcount and returns the same pointer:
fn main() -> i64 {
let original = "field notes";
let shared = original.clone(); // no copy — shared and original alias one buffer
var edited = original; // original moves into edited; still aliased with shared
edited = edited + "!"; // concat builds a fresh buffer; shared's is untouched
shared.len() // 11 — shared's buffer is untouched by edited's write
}This runs and exits 11. The .clone() doesn’t duplicate "field notes"; it hands back the same buffer so two owners point at one allocation. (For this particular string it’s even cheaper than a refcount bump — "field notes" is a static literal, and clone short-circuits on those and returns the pointer without touching a refcount, because a literal is immortal. A heap-produced string takes the retain path.) The write is where I owe you precision. + lowers to the concat primitive, which always allocates a fresh result buffer from its two inputs and releases edited’s reference to the old one — it never mutates in place, whatever the refcount says. shared keeps the original buffer alive and untouched. The true fork — check the refcount, copy only if someone else is watching, mutate in place if you’re the sole owner — exists as a runtime primitive and is unit-tested, but nothing in the language calls it yet, because concat is currently the only mutation surface and concat allocates regardless. So today’s copy-on-write is really the sharing half: clone is a retain, not a copy. The fork-on-write half is built and waiting for an in-place mutation surface to need it.
The memory ordering is the part I lifted directly from std::sync::Arc, because getting it wrong is how you free a buffer that still has live aliases. Retain is fetch_add with Relaxed ordering — a retain only needs to be atomic, it doesn’t synchronize anything. Release is fetch_sub with Release ordering, and only the final release — the one that takes the count to zero — executes an Acquire fence before the free. The fence is what guarantees that every prior owner’s writes happen-before the deallocation; without it, the last releaser could free while another thread’s write to the buffer is still in flight.
There’s one more fail-closed guard. A refcount is a u32, and if a leaked retain loop ever drove the count past half the u32 range, the next bump would be one step from wrapping — and a wrapped count would eventually make a release mistake a still-aliased buffer for the last owner and free it. So retain aborts the moment it observes a count above u32::MAX / 2, well before the wrap, mirroring Arc’s MAX_REFCOUNT discipline. Aborting on a runaway retain is strictly better than the use-after-free a wrapped count would cause later.
A freshly allocated string starts at a refcount of one, so a program that never shares a string — produce it, use it, drop it — behaves exactly as it would have with no refcount at all: one allocation, one free. You only pay for sharing when you share.
&T borrows, and refusing to guess ownership
The third way to use a value without owning it is to borrow it. &T is an immutable, non-owning view:
extern "C" {
// Read the length of s without taking ownership of the buffer.
fn hew_util_strlen(s: &string) -> i32;
}A &string is a read-only window onto a string somebody else owns. The owner keeps its drop; the borrow has none, because a borrow that freed what it borrowed would be a use-after-free for the real owner. Today &T lives where it earns its keep most clearly — at FFI boundaries, where it’s the canonical annotation for “I’m passing a pointer, not handing you the allocation.” Transparent coercion at ordinary Hew call sites — passing a string where a function wants &string and having the borrow happen implicitly — is a type-checker rule I have planned but haven’t landed; for now the borrow marker is honest about being mostly an FFI and read-only-view annotation.
Two things are deliberately not allowed. &mut T and &var T are rejected outright — there are no mutable borrows in this version of the language, which sidesteps the entire aliasing-plus-mutation problem rather than checking my way out of it. And the borrow path is fail-closed against a borrow quietly becoming an owner. The MIR drop prover refuses any shape where it can’t prove a borrow stays a borrow, and when a borrowed view legitimately escapes into an owned sink — a received payload stored into a field, say — codegen inserts an explicit retain, so the buffer gains a real, refcounted second owner rather than a phantom one. An escape that isn’t handled that way traps at runtime instead of proceeding. A borrow that silently became an owner would mean two owners and a double-free down the line, so every path either accounts for the ownership or refuses to continue. The principle is the same one that runs through the whole memory model: if the safe answer isn’t obvious, fail loudly instead of guessing.
Arithmetic traps; it does not wrap
The last piece is what happens when a computation goes wrong. The default is that it traps — loudly, with a distinct exit code, into the supervisor seam — rather than invoking undefined behaviour or silently wrapping.
fn bump(x: i64) -> i64 {
x + 1
}
fn main() -> i64 {
let big: i64 = 9223372036854775807; // i64 maximum
bump(big) // overflows
}Compile that and look at the LLVM IR for the addition — it isn’t a bare add:
%with_overflow = call { i64, i1 } @llvm.sadd.with.overflow.i64(i64 %lhs, i64 %rhs)
%overflow = extractvalue { i64, i1 } %with_overflow, 1
; if %overflow:
call void @hew_trap_with_code(i32 201) ; integer overflow
call void @llvm.trap()Every checked + becomes an overflow-aware intrinsic with a branch to a trap. Run the compiled binary directly and it dies on the overflow — exit 133 natively, which is 128 + SIGTRAP. (Through hew run you’ll see the wrapper’s own exit 1 instead, so don’t let the different number throw you.) There is no garbage value produced and no silent wraparound; i64::MAX + 1 is a fault, not i64::MIN.
The 201 is the part I’m proud of. Hew has a small table of trap discriminators, each one distinct: 201 integer overflow, 202 divide-by-zero, 203 signed MIN / -1 (whose true result isn’t representable), 204 a shift count outside the operand width, and 205 an out-of-bounds index. Inside an actor, the trap stamps that code onto the actor’s error_code before it dies, so the supervisor that restarts it knows why it died — an actor that overflowed and an actor that indexed past the end of a vector are distinguishable failures, not a generic crash. Divide-by-zero and MIN / -1 get their own codes for the same reason:
call void @hew_trap_with_code(i32 202) ; a / 0
; ...
call void @hew_trap_with_code(i32 203) ; i64::MIN / -1If you genuinely want wraparound — a hash mixer, a checksum, a ring counter — you ask for it explicitly with the wrapping methods:
fn wrapping_bump(x: i64) -> i64 {
x.wrapping_add(1) // explicit wrapping add: i64 maximum wraps to the i64 minimum, no trap
}
fn main() -> i64 {
let big: i64 = 9223372036854775807;
let wrapped = wrapping_bump(big);
if wrapped < 0 { 1 } else { 0 } // 1 — wrapped to the i64 minimum, which is negative
}That one exits 1: .wrapping_add(1) wraps i64::MAX to i64::MIN, no intrinsic, no branch, no trap. The wrapping family (.wrapping_add(), .wrapping_sub(), .wrapping_mul(), .wrapping_shl(), .wrapping_shr()) is the explicit opt-in to the behaviour C gives you by default. The asymmetry is the point — the safe thing is what you get for free, and the fast-and-loose thing is what you have to spell out. A reader who sees .wrapping_add knows wraparound was intended; a reader who sees + knows it traps.
Why all of this is one decision
The thread through ownership, copy-on-write, borrows, and traps is that the compiler refuses to guess. Ownership is decided once in MIR and the backend transcribes it. An overflow is a fault with a name, not a wrapped value, and you have to type three characters to get the unsafe behaviour back.
None of it is the most sophisticated version of itself. There are no mutable borrows yet, the string fork-on-write primitive has no caller yet, and call-site borrow coercion is a planned type-checker rule rather than a shipped one. But the parts that are here are here because they make a single MIR portable and a single backend simple — and because the alternative, the compiler quietly deciding memory questions twice and occasionally disagreeing with itself, is the thing I’ve already watched corrupt a heap. Once and carried, or twice and wrong. I’ll keep paying for once and carried.