Name :: distinct T declares a brand-new type that is the exact same bytes as T, with one difference the machine can't see: the type checker labels it Name, not T. Below, the same 8 bytes carry three different labels. Pick a label and watch the bytes not move.
Level 1 was what it is. Now: the situation where not reaching for it is the wrong call — a game with many lookup tables, every one of them keyed on a bare int.
You have a players table, a monsters table, an items table — each keys on an integer id. Somewhere you write damage_player(some_id, 10), and some_id came out of the monster table. Every id is an int, so the type checker has nothing to object to: the bug compiles, ships, and surfaces as a wrong-target hit at runtime. Toggle the design and watch what the compiler can and can't see:
The mechanism: when every table keys on int, "is this the right kind of id?" is a fact the type system literally cannot represent — all ids are interchangeable to it. Give each table its own distinct int and the question becomes a type question, which is exactly the question the compiler already answers on every line. The mix-up stops being a thing you hope code review catches and becomes a thing the build refuses to produce.
The counterfactual: if your program had a single id space — one table, one kind of id, never mixed with anything — you wouldn't need this. A bare int would be honest. distinct earns its keep precisely when you have several id kinds (or several units, or raw-vs-validated values) that share a representation and must never silently cross.
Keep using bare int and stay careful. This is the default most reach for, and it's the one that loses: the discipline lives in human attention, which is exactly the resource that runs out at 2 a.m. on the tenth table. The compiler sees nothing wrong, so the bug class is permanent.
Wrap each id in a one-field struct{ value: int }. This gets you real distinctness, but now every read site spells out .value, construction is a struct literal, you can't use the wrapper directly where an integer is wanted, and arithmetic between two of them no longer works without a helper. More code at every touch point, same protection distinct gives you for one line — and the extra ceremony is what tempts people back to the bare int.
Encode the kind in a name or a comment (player_id_42, // monster id!): documentation the compiler can't read and won't enforce. The instant someone copies the value into another variable the hint is gone.
Level 2 sold you on it. Level 3 is the bill — and notice the bill is not bytes. The runtime cost is zero (Level 1). What you pay is an explicit cast at every boundary where you genuinely want to cross types.
The same rule that catches the wrong-id bug also stops you from mixing a distinct value with its own underlying type — so when you legitimately need to, you say so out loud with a conversion. The line between "free" and "needs a cast" is sharp and worth knowing exactly:
The fix is the feature: the cast int(p1) emits zero instructions — it's a type-checker no-op that peels the label off for the next operation. So the price isn't speed, it's three keystrokes that make crossing the boundary visible and greppable. Every int(id) or f32(meters) in the source is a place you told the compiler "I mean to leave the safe zone here," and you can find them all with a search. The bill is paid in honesty, not cycles.
The last level isn't bytes or syntax — it's the correctness property that emerges once distinct types are threaded through a whole codebase: a class of silent bugs becomes a class of loud compile errors, each one naming the exact two types you crossed.
Every "you can't mix these" rule from Level 3 is a real compile error, not a lint or a convention. Here are four of them, verbatim from the compiler — pick one:
The emergent payoff: read those messages — each one names both sides of the mix ('Monster_Id' vs 'Player_Id', 'Meters' vs 'Seconds'). At a glance you know whether you crossed two siblings or crossed a distinct type with its raw underlying type, and the message points at the exact line. Across a real codebase with dozens of id kinds and unit kinds, "did I ever mix these up anywhere?" stops being a runtime bug hunt and becomes something the compiler answers for you at every build. The famous version of this bug — a spacecraft lost because one team's numbers were one unit and the consuming team read them as another, same bytes, wrong meaning — is, in these terms, just a missing distinct and the compile error that would have caught it.
That's the arc: L1 same bytes, new label — zero runtime cost → L2 so several same-shaped kinds (ids, units) can be held apart instead of silently mixed → L3 the bill is an explicit, greppable cast at every real crossing, not a single CPU cycle → L4 every crossing the compiler rejects names both types, turning a silent bug class into a compile-time one.