A database may tell us that a user ID and a file ID are both 64-bit integers. The business domain tells us something much more important: they are not interchangeable.
That difference sounds obvious when written in English. It is surprisingly easy to erase it 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 backward, but the compiler has no reason to complain. We told it that both values are merely i64, so it faithfully checked that we passed two i64 values.
This is not really a compiler failure. It is a modeling failure. We knew more than the type system because we threw away the meaning of the values before asking the compiler for help.
Nominal types let us put that meaning back.
A Type Alias Is Not a New Type
The first instinct is often to give each primitive a descriptive alias:
type UserId = i64;
type FileBlobId = i64;That improves readability, but it does not improve safety. UserId and FileBlobId are still two names for exactly the same type. They remain interchangeable.
The same thing happens in TypeScript:
type UserId = number;
type FileBlobId = number;These aliases document intent for a human reader, but neither compiler sees a meaningful boundary.
What we want is nominal typing: two values should be incompatible because they were declared as different types, even when their underlying representation is identical.
TypeScript Branded Types: Nominality in a Structural World
TypeScript is structurally typed. If two values have the same shape, TypeScript generally considers them compatible. That is one of the language's strengths, but it means primitive domain values need a small trick if we want nominal behavior.
A common solution is a branded type:
declare const userIdBrand: unique symbol;
declare const fileBlobIdBrand: unique symbol;
type UserId = number & {
readonly [userIdBrand]: "UserId";
};
type FileBlobId = number & {
readonly [fileBlobIdBrand]: "FileBlobId";
};The runtime value is still a number. The brand exists only for the type checker, where it makes the two values incompatible:
function attachFile(userId: UserId, fileId: FileBlobId): void {
// ...
}
declare const userId: UserId;
declare const fileId: FileBlobId;
attachFile(fileId, userId); // Type errorThat is already a huge improvement. A bug that previously looked perfectly valid is now rejected before the code runs.
The usual next step is to centralize validation in constructor functions:
function createUserId(value: number): UserId {
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(`Invalid user ID: ${value}`);
}
return value as UserId;
}
function createFileBlobId(value: number): FileBlobId {
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(`Invalid file blob ID: ${value}`);
}
return value as FileBlobId;
}The assertion is confined to a trusted boundary, while the rest of the application deals in meaningful types.
Branded types are useful precisely because they are so cheap to introduce. They add no wrapper object, allocate nothing, serialize like their underlying primitive, and can often be adopted incrementally in an existing application.
They do have one weakness: the guarantee is a convention built on top of TypeScript's type system. A developer can always write value as UserId, and the brand disappears completely when JavaScript is emitted. Data arriving from JSON, a database, local storage, or a network response must still be validated before the brand means anything.
That does not make brands fake or useless. All static types need care at untyped boundaries. It simply means TypeScript gives us a very good imitation of nominal typing rather than a language-level nominal type.
Rust's Newtype Pattern
Rust does not require the branding trick. Defining a tuple struct creates a genuinely new type:
struct UserId(i64);
struct FileBlobId(i64);Both values contain an i64, but the compiler considers them unrelated. The types have different identities 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 errorThis pattern is called the newtype pattern: wrap an existing representation in a single-field type so the domain concept gets its own identity and behavior.
The basic idea is tiny. A production-quality version can do considerably more.
The Macro That Made Me Stop and Admire It
Here is the implementation that prompted this article:
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);
$(#[$attribute])*
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!(
#[cfg_attr(
not(test),
allow(
dead_code,
reason = "reserved for product-media service boundaries in later workstream items"
)
)]
FileBlobId
);It looks like a convenience macro. It is really a small factory for domain laws.
Every invocation creates a type that is distinct, validated, cheap, printable, comparable, sortable, hashable, and difficult to construct incorrectly. The macro removes the repetition without removing the meaning.
Let us unpack why each part matters.
#[repr(transparent)]: New Meaning, Same Representation
#[repr(transparent)]
pub(crate) struct UserId(i64);UserId is a separate type to the Rust compiler, but repr(transparent) guarantees that its representation follows the single i64 field it wraps.
Conceptually, we get this combination:
Compile-time meaning: UserId
Runtime representation: i64There is no extra tag stored beside the integer and no heap allocation merely because we modeled the value correctly. We gain a stronger type without paying for a wrapper object at runtime.
This is the good kind of zero-cost abstraction: the domain model gets richer while the underlying data stays simple.
The Private Field Protects Construction
There is a subtle but essential detail here:
pub(crate) struct UserId(i64);The type is visible throughout the crate, but its inner field is not public. Code outside the defining module cannot casually construct UserId(0) or UserId(-50).
That turns construction into a controlled boundary. Instead of trusting every caller to remember the rules, the type owns its own validity condition.
impl TryFrom<i64> for UserId {
type Error = InvalidEntityId;
fn try_from(value: i64) -> Result<Self, Self::Error> {
if value < 1 {
return Err(InvalidEntityId {
kind: "UserId",
value,
});
}
Ok(Self(value))
}
}Once ordinary safe code has a UserId, it knows more than merely "this is an integer." It knows the value passed the entity-ID rule at its construction boundary.
That is where Rust begins to pull ahead of a TypeScript brand. In TypeScript, the constructor function is a convention because another assertion can bypass it. In Rust, field privacy is enforced by the language. Bypassing it requires leaving the ordinary safe abstraction boundary instead of adding a casual as UserId at a call site.
TryFrom Makes Validation Part of Conversion
Using TryFrom<i64> communicates that turning an arbitrary integer into an entity ID can fail:
let user_id = UserId::try_from(database_value)?;That line works equally well at database, HTTP, message-queue, and test-fixture boundaries. It also composes naturally with Rust's Result and ? operator.
An infallible From<i64> implementation would lie. Not every i64 is a valid ID, so the conversion should not pretend otherwise.
The macro uses stringify!($name) when constructing the error. Each generated type therefore identifies itself without repeating a fragile string literal:
InvalidEntityId { kind: "UserId", value: 0 }That is a small example of the compiler keeping code and diagnostics synchronized.
get() Is an Explicit Escape Hatch
Eventually an ID must cross a boundary that expects its primitive representation. A SQL parameter, serializer, or low-level API may need the underlying i64.
impl UserId {
pub(crate) const fn get(self) -> i64 {
self.0
}
}The method makes that loss of meaning explicit:
query.bind(user_id.get());The type does not implicitly behave like an integer everywhere. Application code must consciously unwrap it at the boundary where an integer is truly required.
Because the ID is Copy, taking self is trivial. Because get is const, it can also be used in compile-time contexts where Rust permits it.
The Derived Traits Define Sensible ID Behavior
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]These traits give an entity ID the operations we naturally expect:
- copying and cloning
- debugging
- equality checks
- ordering
- use as a hash-map or hash-set key
Notice what is not implemented: arithmetic.
Adding two user IDs would be meaningless. Multiplying a file ID by three would be nonsense. A raw i64 offers those operations automatically; the newtype exposes only the capabilities appropriate to an identity.
This is an underrated advantage of nominal wrappers. They do not merely stop one ID kind from becoming another. They also shrink the legal operation set from "everything an integer can do" to "everything an ID should do."
Display Preserves Ergonomics Without Sacrificing Meaning
impl fmt::Display for UserId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}The ID remains easy to put in logs, messages, and formatted output:
tracing::info!(%user_id, "loading user");Strong types become unpopular when they make routine work unnecessarily painful. Implementing the small ergonomic traits keeps the safety benefit from turning into ceremony.
The Macro Makes the Safe Choice the Easy Choice
Without the macro, each new ID would need a struct, derives, validation, accessors, formatting, and tests. Repetition creates pressure to take shortcuts:
type AnotherId = i64;With the macro, introducing a real domain type costs one line:
entity_id!(InvoiceId);
entity_id!(CustomerId);
entity_id!(OutletId);The macro also accepts attributes and forwards them to the generated type and implementation. That is why FileBlobId can carry its narrowly documented cfg_attr without requiring a second macro or a hand-written exception.
This is where the implementation feels magical. Not because macros are mysterious, but because a tiny declaration expands into a consistent package of guarantees.
What Bugs Become Impossible?
Suppose a service has this interface:
fn attach_file(user_id: UserId, file_id: FileBlobId) {
// ...
}The compiler now rejects all of these mistakes:
attach_file(file_id, user_id);
if user_id == file_id {
// ...
}
let ids: Vec<UserId> = vec![file_id];It also prevents ordinary callers from creating an ID that violates the positive-integer invariant.
This protection spreads naturally through the codebase. Function signatures become documentation that cannot silently drift away from implementation. Refactoring becomes safer because every incorrect connection is surfaced by the compiler. Code review can focus on business behavior instead of manually tracing which integer came from which table.
The best part is that none of those benefits require a runtime registry, reflection system, or validation framework. They emerge from giving the compiler the same vocabulary the domain already uses.
TypeScript Brands Versus Rust Newtypes
Both approaches solve the most important problem: they stop semantically different values from being mixed accidentally in normal typed code.
| Concern | TypeScript branded type | Rust newtype |
|---|---|---|
Distinguishes UserId from FileBlobId |
Yes | Yes |
| Adds wrapper allocation | No | No |
| Keeps primitive JSON ergonomics | Excellent | Requires explicit boundary handling |
| Supports incremental adoption | Excellent | Good, but usually more invasive |
| Controls construction | By exported API and convention | Enforced through field privacy |
| Can be bypassed casually | Yes, with a type assertion | Not through ordinary safe code outside the privacy boundary |
| Owns methods and trait behavior | Indirectly, through functions or namespaces | Directly on the type |
| Can restrict meaningless primitive operations | Mostly through typing discipline | Naturally |
| Runtime representation overhead | None | None with this transparent wrapper |
TypeScript wins on convenience. A branded number flows through existing JavaScript and JSON code with almost no friction. It is a fantastic technique for a structurally typed language, especially when paired with runtime validation at external boundaries.
Rust wins on rigor. The newtype is not pretending to be nominal; it is nominal. Privacy protects its constructor, trait implementations define its legal behavior, fallible conversion owns validation, and repr(transparent) preserves the efficient representation underneath.
Overall, I would give Rust the marginal win. It provides a more complete abstraction, but "marginal" matters here: TypeScript brands deliver most of the practical protection for dramatically less migration effort in a JavaScript codebase. The best choice is not the language with the prettier trick. It is the strongest model that fits the system you are actually building.
The Real Magic Is Moving Knowledge Into the Type System
Before the newtypes, the application knew these facts informally:
- IDs must be positive.
- A user ID is not a file ID.
- IDs may be compared and hashed.
- IDs should not participate in arithmetic.
- Database code sometimes needs the underlying integer.
After the newtypes, those facts are executable.
That is the deeper lesson. Primitive types describe storage. Domain types describe meaning.
An i64 tells me how many bits a value occupies and which arithmetic operations are available. UserId tells me what the value is, how it may be created, where it belongs, and which operations make sense. Both may compile down to the same bits, but only one lets the compiler participate in the design.
Once you see that difference, passing raw integers around important domain code starts to feel like leaving holes in the type system.
TypeScript branded types patch those holes beautifully. Rust newtypes close them just a little more completely. And a well-designed macro makes doing the stronger thing almost effortless.
comments
view on github ->