The database says a user ID and a file ID are both 64-bit integers. The domain says something more important: they're not interchangeable.
That's obvious in English. It's surprisingly easy to lose in code.
fn attach_file(user_id: i64, file_id: i64) {
// ...
}
let user_id = 42;
let file_id = 91;
attach_file(file_id, user_id);The arguments are backwards and the compiler has nothing to say about it. I told it both values were i64, and it faithfully checked that I passed two i64s.
That's not a compiler failure. It's a modeling failure. I knew more than the type system did, and I threw that knowledge away before asking it for help.
Nominal types are how you put it back.
A type alias isn't a new type
The first instinct is usually an alias:
type UserId = i64;
type FileBlobId = i64;That helps a human reading the signature. It doesn't help the compiler. UserId and FileBlobId are two names for the same type, and they're still interchangeable.
Same story in TypeScript:
type UserId = number;
type FileBlobId = number;What I actually want is for two values to be incompatible because they were declared as different types, even when the bits underneath are identical. That's nominal typing.
TypeScript: branded types
TypeScript is structurally typed. Two values with the same shape are compatible, full stop. That's one of the language's strengths, and it means primitives need a small trick before they'll behave nominally.
The trick is a brand:
declare const userIdBrand: unique symbol;
declare const fileBlobIdBrand: unique symbol;
type UserId = number & {
readonly [userIdBrand]: "UserId";
};
type FileBlobId = number & {
readonly [fileBlobIdBrand]: "FileBlobId";
};At runtime the value is still a number. The brand only exists for the type checker, and there it does exactly what I wanted:
function attachFile(userId: UserId, fileId: FileBlobId): void {
// ...
}
declare const userId: UserId;
declare const fileId: FileBlobId;
attachFile(fileId, userId); // Type errorThe next step is to put construction behind a function so the validation lives in one place:
function createUserId(value: number): UserId {
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(`Invalid user ID: ${value}`);
}
return value as UserId;
}The as is confined to one trusted spot. Everything past it deals in UserId.
Brands are cheap, which is why they're so easy to adopt. No wrapper object, no allocation, they serialize as plain numbers, and you can introduce them one type at a time in an existing codebase.
The weakness is that it's a convention. Anyone can write value as UserId anywhere, and the brand vanishes when the code is emitted to JavaScript. Data from JSON, a database or a network response has to be validated before the brand means anything. That's true of all static types at untyped boundaries, so I don't hold it against brands. It just means TypeScript gives you a very good imitation of nominal typing rather than the real thing.
Rust: the newtype pattern
Rust doesn't need the trick. A tuple struct is a genuinely new type:
struct UserId(i64);
struct FileBlobId(i64);Both hold an i64. The compiler treats them as unrelated, because they have different declarations.
fn attach_file(user_id: UserId, file_id: FileBlobId) {
// ...
}
let user_id = UserId(42);
let file_id = FileBlobId(91);
attach_file(file_id, user_id); // Compile-time errorThat's the newtype pattern. The basic idea is two lines. The version I actually use does a bit more.
The macro I use
I have a lot of ID types, so they come from a macro:
macro_rules! entity_id {
($(#[$attribute:meta])* $name:ident) => {
$(#[$attribute])*
#[repr(transparent)]
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub(crate) struct $name(i64);
impl $name {
pub(crate) const fn get(self) -> i64 {
self.0
}
}
impl TryFrom<i64> for $name {
type Error = InvalidEntityId;
fn try_from(value: i64) -> Result<Self, Self::Error> {
if value < 1 {
return Err(InvalidEntityId {
kind: stringify!($name),
value,
});
}
Ok(Self(value))
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
};
}
entity_id!(UserId);
entity_id!(FileBlobId);Each line creates a type that's distinct, validated, Copy, printable, comparable, hashable and hard to construct wrong. The $(#[$attribute])* part forwards any attributes you put in front of the name, like a doc comment or an #[allow(dead_code)], onto the generated struct.
Here's what each piece is doing.
#[repr(transparent)]
#[repr(transparent)]
pub(crate) struct UserId(i64);A single-field struct already costs nothing at runtime. There's no tag stored next to the integer and no allocation; the wrapper is purely a compile-time idea. What repr(transparent) adds is a guarantee: the struct's layout and ABI are exactly those of the i64 inside. That only matters if the type crosses an FFI boundary or gets transmuted, but it costs nothing to promise, so I put it on every newtype over a primitive.
The private field
pub(crate) struct UserId(i64);The type is visible across the crate. The field isn't. Code outside the defining module can't write UserId(0) or UserId(-50).
That makes construction a controlled boundary. The type owns its own validity rule instead of every caller having to remember it.
This is where Rust pulls ahead of a TypeScript brand. In TypeScript the constructor function is a convention, because as UserId works anywhere. In Rust, field privacy is enforced by the language.
TryFrom instead of From
let user_id = UserId::try_from(database_value)?;Not every i64 is a valid ID, so the conversion can fail, so it's TryFrom. An infallible From<i64> would be a lie. TryFrom also composes with ?, which makes it pleasant at every boundary: database rows, HTTP params, queue messages, test fixtures.
The error uses stringify!($name), so each type names itself without a string literal that could drift:
InvalidEntityId { kind: "UserId", value: 0 }get() is the explicit way out
Eventually the ID has to become an integer again, for a SQL parameter or a serializer.
query.bind(user_id.get());I want that to be a visible step, not something that happens implicitly. UserId doesn't Deref to i64 and doesn't implement Into<i64>. If you need the number, you say so. Since the type is Copy, taking self is free, and const fn means it works in const contexts too.
The derives, and what's missing from them
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]Copy, compare, sort, hash, print for debugging. Everything an ID should be able to do.
Notice what isn't there: arithmetic. Adding two user IDs is meaningless. A raw i64 offers + and * whether you want them or not; the newtype exposes only the operations that make sense for an identity. I think this is the underrated half of newtypes. They don't just stop one ID from becoming another. They shrink the legal operation set from "everything an integer does" to "everything an ID should do".
Display, so it's not annoying
tracing::info!(%user_id, "loading user");Strong types get abandoned when they make routine work painful. Implementing Display keeps IDs easy to log and format, so there's no temptation to .get() them just to print them.
Why the macro matters
Without it, every new ID needs a struct, derives, validation, an accessor, Display and tests. That's enough friction that at some point someone writes type AnotherId = i64; and the whole scheme leaks.
With it, a real domain type costs one line:
entity_id!(InvoiceId);
entity_id!(CustomerId);
entity_id!(OutletId);The safe choice has to be the easy choice, or it won't stay the choice.
What becomes impossible
Given fn attach_file(user_id: UserId, file_id: FileBlobId), all of these are now compile errors:
attach_file(file_id, user_id);
if user_id == file_id {
// ...
}
let ids: Vec<UserId> = vec![file_id];And ordinary code can't create an ID that violates the positive-integer rule.
Function signatures become documentation that can't drift. Refactors get safer because every wrong connection surfaces at compile time. Code review can spend its attention on behavior instead of tracing which integer came from which table. None of that needs a runtime registry or a validation framework. It comes from giving the compiler the same vocabulary the domain already has.
Brands versus newtypes
| Concern | TypeScript brand | Rust newtype |
|---|---|---|
Distinguishes UserId from FileBlobId |
Yes | Yes |
| Runtime overhead | None | None |
| Works with plain JSON | Yes, transparently | Needs explicit conversion at the boundary |
| Incremental adoption | Easy | Doable, but more invasive |
| Controls construction | By convention | Enforced by field privacy |
| Can be bypassed | Yes, with as |
Not from safe code outside the module |
| Owns methods and trait impls | Indirectly | Directly on the type |
| Restricts meaningless operations | Only by discipline | By default |
TypeScript wins on convenience. Rust wins on rigor. Use whichever fits the system you're actually building; a brand that ships beats a newtype that never lands.
The actual point
Before the newtypes, the application knew these facts informally:
- IDs are positive.
- A user ID isn't a file ID.
- IDs can be compared and hashed.
- IDs shouldn't do arithmetic.
- Database code sometimes needs the raw integer.
After the newtypes, those facts are executable.
An i64 tells you how many bits a value takes up. UserId tells you what the value is, how it's created, where it belongs and which operations make sense. Both compile to the same bits. Only one of them lets the compiler take part in the design.
Once you've seen that, passing raw integers around domain code starts to look like leaving holes in the type system. Brands patch them. Newtypes close them a little more completely. And a small macro makes the stronger option almost free.
comments
view on github ->