Branded Types: Nominal Typing in a Structural Type System
AI generated
<T>
type
TypeScript · Branded Types · Nominal Typing · Type Safety
Branded Types: Nominal Typing in a Structural Type System
Why two identically shaped strings still do not mean the same thing

TypeScript checks types by their shape, not by their name. A UserId and an OrderId look identical to the compiler as long as both are declared as string, which means they can be swapped by accident. Branded types close exactly this gap by adding a nominal distinction on top of a structural type system, with zero runtime cost.

11 min read Brand Pattern · Nominal Typing · Opaque Types TypeScript 5.x

1. The Problem: Structural Typing Lets IDs Get Mixed Up

TypeScript uses a structural type system: two types are considered compatible as soon as they have the same shape, regardless of how they are named or what they actually represent. In most cases this is an advantage, because it enables duck typing and makes interfaces easy to combine. For simple primitives such as string or number, though, that same advantage quickly turns into a trap: a function function cancelOrder(orderId: string) happily accepts a UserId, an email address, or any other string as well, as long as the type is just string.

In a growing codebase with many different ID types, validated input formats, and money amounts, this risk adds up. A swapped parameter in transferFunds(fromAccountId, toAccountId) goes unnoticed by the compiler, because both parameters carry the same type string, and in the worst case only surfaces in production. Branded types solve this problem by giving structurally identical primitives an additional marker that is only visible at compile time, which the compiler checks on every assignment.

2. Fundamentals: What Are Branded Types?

Branded types, also called tagged types or nominal types, simulate nominal typing inside a structural type system. Nominal typing means that two types are only compatible if they explicitly share the same name, the way languages with a nominal type system such as Java or C# handle it by default. TypeScript does not have this concept natively, but it can be reproduced with a simple trick: extend a base type with an additional, purely virtual property that never exists at runtime.

The result is a type that behaves, from the compiler's point of view, like a distinct, unmistakable type, even though at runtime it stays a perfectly ordinary string or number. Two branded types with different tags, for example UserId and OrderId, are no longer mutually assignable for the compiler, even if their structural foundation is identical. That artificial distinction is the entire purpose of the pattern.

3. Implementation: An Intersection Type With a Tag Property

The most common implementation combines the base type with an intersection type against an object that carries a single, uniquely named property, usually __brand or __tag. A generic helper such as type Brand<T, TBrand extends string> = T & { readonly __brand: TBrand } makes this pattern reusable for any base type and tag name. It is important that this property is never actually populated with real values, it exists only so the compiler treats two otherwise identical types as incompatible.

Because a plain string does not carry a __brand field, TypeScript does not automatically accept a raw string as a UserId, the assignment requires an explicit, visible type assertion. This friction is intentional: it forces the transition from unstructured input to trusted, branded values to happen at a single, deliberately chosen place in the code, instead of casting on demand everywhere throughout the system.


// Brand pattern: intersect the base type with a unique tag property
type Brand<T, TBrand extends string> = T & { readonly __brand: TBrand };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

declare function getUser(id: UserId): void;

const rawId: string = "usr-123";
// getUser(rawId); // compile error: string is not assignable to UserId
// getUser(rawId as UserId); // works, but requires an explicit, visible cast

4. Safe Construction: Factory Functions and Validation

A raw type assertion such as rawId as UserId solves the mix up problem, but on its own it guarantees nothing about actual correctness, because the compiler only checks the shape, not the real value. In practice, branded types are therefore almost always combined with a factory function that is the only place in the code authorized to create a branded type, and that performs a real runtime check while doing so.

This factory function is typically exported, while the underlying type assertion stays private inside the same module. For the rest of the codebase there is then no way left to produce a UserId value except through toUserId(), which bundles validation and type safety into a single, easily locatable place instead of scattering it across the entire application.


// Factory function is the only place allowed to mint a branded value
function toUserId(value: string): UserId {
  if (!value.startsWith("usr-")) {
    throw new Error(`Invalid UserId: ${value}`);
  }
  return value as UserId;
}

function toOrderId(value: string): OrderId {
  if (!value.startsWith("ord-")) {
    throw new Error(`Invalid OrderId: ${value}`);
  }
  return value as OrderId;
}

const userId = toUserId("usr-123");   // UserId
const orderId = toOrderId("ord-456"); // OrderId

5. Use Case: Distinguishing IDs of Different Entities

The most common and most rewarding use case for branded types is identifiers. As soon as an application has more than one entity with an ID, for example customers, orders, and products, every string typed ID signature becomes a silent source of bugs, because a CustomerId is structurally indistinguishable from a ProductId. Repositories and service methods that instead expect branded types such as UserId and OrderId reject a mix up already at compile time.

This protection is especially valuable at interfaces with several similarly looking parameters, for example functions with three or four ID arguments in a row. Without branded types you rely entirely on code review and tests to catch a wrong order. With branded types the compiler takes over this check automatically and reliably, on every single call, without any extra testing effort.


interface OrderRepository {
  findByCustomer(customerId: UserId): Order[];
  findById(orderId: OrderId): Order | undefined;
}

declare const repository: OrderRepository;
declare type Order = { id: OrderId; customerId: UserId };

const customer = toUserId("usr-123");
const order = toOrderId("ord-456");

repository.findByCustomer(customer); // fine
// repository.findByCustomer(order); // compile error: OrderId is not assignable to UserId
// A plain "string" parameter would have accepted this silently

6. Use Case: Validated and Sanitized Strings

A second strong use case is distinguishing between unvalidated and already checked strings. A function sendWelcomeMail(to: string) cannot enforce that the value passed in is actually a valid email address, it can only hope that validation happened somewhere earlier. A branded type Email makes that hope unnecessary: only someone who has successfully passed the value through a validation function ends up with a value of type Email at all, and only such a value can be passed to sendWelcomeMail.

The same pattern works for sanitized HTML, checked file paths, or normalized postal codes. Instead of re running validation at every point of use, it happens once, at the transition from raw to branded string, and the compiler afterward guarantees that a repeated check in deeper layers of the application is unnecessary, because an unvalidated value cannot take on the type Email in the first place.


type Email = Brand<string, "Email">;

function isEmail(value: string): value is Email {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

function sendWelcomeMail(to: Email): void {
  // implementation omitted, only reachable with a validated Email
}

function tryEmail(input: string): Email | undefined {
  return isEmail(input) ? input : undefined;
}

const candidate = tryEmail("dev@mironsoft.de");
if (candidate) {
  sendWelcomeMail(candidate); // narrowed to Email, no re-validation needed downstream
}

7. Use Case: Money Amounts and Units

Money amounts are a classic case where a plain number type is dangerously underspecified: does the value represent cents or euros, net or gross? Mixing up cents and euros is one of the most common and most expensive mistakes in payment systems, precisely because a factor of one hundred often looks plausible at first glance. Branded types such as Cents and Dollars turn that unit into a type property, so a function that triggers a payment to a provider accepts only Cents and rejects a raw or dollar denominated amount.

Converting between the units remains possible at any time, but it must happen explicitly through a conversion function that itself returns the matching branded type. The same technique works for any physical or domain specific unit, for example meters versus millimeters or net versus gross prices, anywhere a plain number type silently swallows the decisive information about the unit.


type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;

function centsToDollars(value: Cents): Dollars {
  return (value / 100) as Dollars;
}

function dollarsToCents(value: Dollars): Cents {
  return Math.round(value * 100) as Cents;
}

function chargeCard(amount: Cents): void {
  // the payment provider always expects the smallest currency unit
}

const price: Dollars = 19.99 as Dollars;
chargeCard(dollarsToCents(price)); // explicit, deliberate conversion
// chargeCard(price); // compile error: Dollars is not assignable to Cents

8. Runtime Cost: Why Branded Types Cost Nothing

A decisive advantage of branded types over alternatives such as wrapper classes is that they disappear completely at runtime. The tag property __brand exists only in the type system and is fully erased when compiling down to JavaScript, just like any other pure type information. A UserId value is, at runtime, exactly the same primitive string as any other string, with no extra object, no extra property, and no measurable allocation at all.

That sets branded types fundamentally apart from a wrapper class such as class UserId { constructor(public value: string) {} }, which offers the same nominal typing guarantee but allocates a real object on the heap on every creation, complicates comparisons with ===, and adds extra work to JSON serialization. Branded types deliver the compile time safety of a wrapper class at the zero cost of a pure type annotation, a rare case where more type safety does not have to be bought with more runtime cost.

9. Branded Types in Comparison

Not every place in the code benefits from a branded type. The table below shows typical scenarios and makes clear when the extra effort of a tag pattern actually pays off.

Scenario Without branded types With branded types Benefit
IDs of different entities userId: string, orderId: string UserId, OrderId as separate brands Compiler prevents accidental mix ups
Validated user input email: string with no check Email brand via type guard Validated once, guaranteed valid afterward
Money amounts and units amount: number with no unit Cents, Dollars as separate brands Prevents mix ups such as cents instead of euros
Runtime cost Wrapper class with its own instance Brand via intersection type Disappears completely at compile time
Protection against forgery String literal tag field, easy to replicate unique symbol as the brand key Brand cannot accidentally be matched from outside

The rule of thumb: branded types pay off wherever several structurally identical primitives mean different things from a domain perspective, above all for IDs, validated input, and units. For one off, uncritical strings or numbers with no realistic risk of confusion, a plain primitive type remains the right, simpler choice.

Mironsoft

TypeScript tooling, type safety, and type safe headless integrations

IDs, amounts, and inputs that can no longer be mixed up?

We review existing TypeScript types for silent mix up risks around IDs, money amounts, and user input, introduce branded types where it makes sense, and build type safe domain models for your Magento and headless stack.

Type Level Review

Checking existing types for mix up risks around IDs and units

Domain Modeling

Introducing branded types for IDs, validated strings, and money amounts

Build Tooling

Strict tsconfig settings and type checks in the CI pipeline

10. Summary

Branded types close a concrete gap in TypeScript's structural type system: two values with identical shape but different domain meaning, for example a UserId and an OrderId, can otherwise be swapped by accident. The brand pattern extends a base type through an intersection type with a purely virtual tag property that exists only at compile time and forces the compiler to treat the two types as incompatible. Factory functions with real runtime validation ensure that a branded type is trustworthy not just formally, but in substance too.

The three strongest practical use cases are IDs of different entities, validated or sanitized strings such as email addresses, and money amounts with different units such as cents and euros. In all three cases the compiler prevents mistakes that would otherwise only surface in tests or in production. The decisive advantage over wrapper classes is that the entire tag property disappears completely at compile time: full nominal typing safety, with zero runtime cost.

Branded Types in TypeScript - The Key Points at a Glance

Basic Pattern

T & { readonly __brand: TBrand } makes structurally identical types incompatible for the compiler.

Safe Construction

Factory functions validate at runtime and are the only place allowed to create a branded type.

Use Cases

IDs, validated strings such as Email, and money amounts with units such as Cents and Dollars.

Runtime Cost

Zero. The tag property exists only in the type system and is fully erased when compiling to JavaScript.

11. FAQ: Branded Types in TypeScript

1What is a branded type in TypeScript?
A base type extended through an intersection type with a purely virtual tag property that exists only at compile time and makes the type distinguishable.
2Why is a plain string type not enough for IDs?
TypeScript checks structurally, not nominally. Two string IDs such as UserId and OrderId look identical to the compiler and can be swapped unnoticed.
3Do branded types cost anything at runtime?
No. The tag property disappears completely at compile time, a branded type value stays a plain primitive at runtime.
4How do you safely create a value of a branded type?
Through a factory function that validates the raw value and only then converts it via a type assertion, as the only authorized place in the module.
5Branded type or wrapper class?
Both offer nominal typing, but the wrapper class allocates a real object. The branded type disappears completely at compile time.
6What are branded types useful for with validated strings?
For values such as Email, where a type guard validates once and deeper code layers can rely on validity afterward.
7How do branded types prevent mistakes with money amounts?
Cents and Dollars as separate brands make a payment function accept only Cents, a swapped amount triggers a compile error.
8Can a type assertion bypass the brand protection?
Technically yes. The protection relies on discipline: only the factory function should cast, ideally backed by lint rules.
9What does a unique symbol as the brand key mean?
A private, module internal unique symbol instead of a string literal makes it harder to accidentally replicate the brand from outside.
10When are branded types not worth it?
For one off, uncritical values with no realistic risk of confusion, where a plain string is entirely sufficient.