ashik@dev

The Magic of Nominal Types in Rust and TypeScript

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.

rust
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:

rust
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:

ts
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:

ts
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:

ts
function attachFile(userId: UserId, fileId: FileBlobId): void {
  // ...
}

declare const userId: UserId;
declare const fileId: FileBlobId;

attachFile(fileId, userId); // Type error

That 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:

ts
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:

rust
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.

rust
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 error

This 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:

rust
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

rust
#[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:

text
Compile-time meaning: UserId
Runtime representation: i64

There 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:

rust
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.

rust
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:

rust
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:

text
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.

rust
impl UserId {
    pub(crate) const fn get(self) -> i64 {
        self.0
    }
}

The method makes that loss of meaning explicit:

rust
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

rust
#[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

rust
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:

rust
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:

rust
type AnotherId = i64;

With the macro, introducing a real domain type costs one line:

rust
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:

rust
fn attach_file(user_id: UserId, file_id: FileBlobId) {
    // ...
}

The compiler now rejects all of these mistakes:

rust
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.

found a typo? posts live in git.suggest an edit ->

comments

view on github ->