Skip to content
C Codeloom
TypeScript

TypeScript Branded Types for Nominal Typing

Add nominal typing to TypeScript using branded types so you cannot accidentally mix UserId, OrderId, raw strings, or money values.

·6 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • The difference between structural and nominal typing
  • How branded types simulate nominal types in TypeScript
  • Building safe ID, currency, and unit types
  • Constructor functions and validation patterns
  • Trade-offs around DX and runtime cost

Prerequisites

  • TypeScript basics from /blog/typescript-basic-types
  • Generics from /blog/typescript-generics-basics

TypeScript is structurally typed, which means any two types with the same shape are interchangeable. That works well for plain data, but it also means a UserId, an OrderId, and a raw string are all the same type, and the compiler will happily let you swap them. Branded types add a layer of compile-time identity so the compiler can tell them apart.

Structural vs Nominal

A structural type system compares the shape of types. A nominal type system compares their names. Most ML-family languages, Java, C-sharp, and Swift are nominal. TypeScript, Go’s interfaces, and Flow are structural.

The structural model is convenient because you can pass any compatible object without ceremony, but it also lets bugs slip through. If a function takes a UserId and another returns an OrderId, both aliased to string, the compiler cannot stop you from swapping them.

type UserId = string;
type OrderId = string;

function fetchUser(id: UserId) { /* ... */ }

const orderId: OrderId = 'order-42';
fetchUser(orderId); // compiles, but semantically wrong

Branded types fix this by attaching a fake property that exists only at the type level.

The Brand Pattern

A brand is an intersection with a tag the rest of the codebase cannot accidentally produce. The tag is unique per type, so two different brands cannot be assigned to each other.

type Brand<T, B> = T & { readonly __brand: B };

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

function fetchUser(id: UserId) { /* ... */ }

const raw = 'order-42';
fetchUser(raw); // Error: missing __brand

At runtime the branded value is just a string. The __brand property never exists in the actual data. TypeScript’s type system is the only thing that enforces the distinction.

Smart Constructors

The brand alone is not enough. You need a way to mint branded values without writing as everywhere. A smart constructor encapsulates the cast in one well-named place and can validate inputs.

function asUserId(raw: string): UserId {
  if (!/^u_[0-9a-f]{8}$/.test(raw)) {
    throw new Error(`Invalid user id: ${raw}`);
  }
  return raw as UserId;
}

const id = asUserId('u_12ab34cd');

The pattern combines runtime validation with compile-time branding. After the call, the value is both checked and tagged. Downstream code can rely on the type without re-validating.

Branding Numbers and Other Primitives

Branding works on any primitive. Use it for IDs as numbers, for currency amounts, for measurements, or for sanitized strings.

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

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

const price = 19.99 as Dollars;
const charge: Cents = toCents(price);
// charge is Cents, never confused with raw numbers

The same trick prevents mixing seconds with milliseconds, meters with feet, and Fahrenheit with Celsius. Every NASA bug is a missing branded type.

Multiple Brands on One Value

A value can carry several brands at once when each represents an orthogonal guarantee. For example, you might brand a string as both Sanitized and Trimmed.

type Sanitized = Brand<string, 'Sanitized'>;
type Trimmed = Brand<string, 'Trimmed'>;

function sanitize(raw: string): string & Sanitized & Trimmed {
  return raw.replace(/[<>]/g, '').trim() as string & Sanitized & Trimmed;
}

function render(input: string & Sanitized) { /* ... */ }

The render function accepts anything that has the Sanitized brand, regardless of whether it also has Trimmed. Stacking brands gives you fine-grained type-level invariants.

Opaque Versus Transparent Brands

There are two stylistic choices for the brand tag. A transparent brand uses a regular property like __brand and is visible in type errors and tooltips. An opaque brand uses a unique symbol declared in a module to hide the tag from consumers.

declare const tag: unique symbol;
type Opaque<T, B> = T & { readonly [tag]: B };

type SessionId = Opaque<string, 'SessionId'>;

Opaque brands prevent consumers from mistakenly assigning to or reading the tag. They also keep IDE hover output cleaner. The downside is that the unique symbol must live in one place and be imported wherever you mint the type.

Helper Utilities

You can wrap the pattern in a small utility module that exposes type aliases and constructors together.

type Branded<T, B extends string> = T & { readonly __brand: B };

function brand<B extends string>() {
  return <T>(value: T): Branded<T, B> => value as Branded<T, B>;
}

const asEmail = brand<'Email'>();
type Email = ReturnType<typeof asEmail>;

const e: Email = asEmail('user@example.com');

This factory pattern keeps brand and constructor names aligned and reduces typos.

When NOT to Brand

If a value has no semantic identity beyond its primitive, do not brand it. A raw page size or a sort order is fine as a plain number. Branding adds friction to every call site, so save it for invariants that actually catch bugs.

Avoid branding values that flow across module boundaries you do not control, because the cast must happen somewhere, and external code will need an escape hatch. Either expose a smart constructor as part of the public API or do not brand the value at all.

Interop With JSON

Branded values serialize identically to their underlying primitive, because the brand is type-only. When parsing JSON, however, you must remember that the result is the raw type, not the branded one. Wrap parsing in a function that validates and re-brands.

function parseUser(json: unknown): { id: UserId; name: string } {
  if (typeof json !== 'object' || json === null) throw new Error('bad input');
  const obj = json as { id: unknown; name: unknown };
  if (typeof obj.id !== 'string' || typeof obj.name !== 'string') {
    throw new Error('bad input');
  }
  return { id: asUserId(obj.id), name: obj.name };
}

This pattern pairs branded types with runtime validators like Zod or io-ts for a complete defense.

For more on TypeScript’s type system, see /blog/typescript-basic-types, /blog/typescript-utility-types, and /blog/typescript-generics-basics.

Wrap up

Branded types simulate nominal typing in a structural type system. They prevent silent mix-ups between IDs, units, and validation states without any runtime cost. Combine them with smart constructors for invariants that survive across module boundaries, and reserve them for values whose identity carries semantic weight.