TypeScriptBranded & Nominal Types

Branded & Nominal Types

TypeScript uses structural typing: two types are compatible if their shapes match, regardless of their names. This is powerful and flexible, but it creates a subtle class of runtime bugs where values of the same underlying type — like two string values representing a user ID and an order ID — can be silently swapped without the compiler catching it.

Branded types (also called nominal types) solve this by attaching a unique "brand" to a primitive type, forcing the compiler to distinguish values that are structurally identical.

The Problem: Structural Typing Bites Back

Consider a simple payment system with three IDs: a UserId, an OrderId, and a ProductId. All three are strings at runtime.

TS
type UserId    = string;
type OrderId   = string;
type ProductId = string;

function getOrder(userId: UserId, orderId: OrderId) {
  console.log(`Fetching order ${orderId} for user ${userId}`);
}

const uid: UserId  = "user-42";
const oid: OrderId = "order-99";

// TypeScript is perfectly happy — both are strings
getOrder(oid, uid); // BUG: arguments are swapped, no error!
Note
Because UserId and OrderId are both aliases for string, TypeScript sees them as identical. The compiler cannot detect the argument swap.
The Solution: Phantom Brands

A brand is a phantom property — it exists only at the type level and is completely erased at runtime. The technique is to intersect the primitive type with an object type that carries a unique literal property:

TS
// Generic brand utility
type Brand<T, B extends string> = T & { readonly _brand: B };

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

Now UserId and OrderId are distinct types even though they are both strings at runtime. To create a branded value you use a constructor function:

TS
function toUserId(id: string): UserId {
  return id as unknown as UserId;
}

function toOrderId(id: string): OrderId {
  return id as unknown as OrderId;
}

function getOrder(userId: UserId, orderId: OrderId) {
  console.log(`Fetching order ${orderId} for user ${userId}`);
}

const uid = toUserId("user-42");
const oid = toOrderId("order-99");

getOrder(uid, oid); // OK
getOrder(oid, uid); // Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'
Success
The compiler now catches the argument swap. The brand is entirely erased at runtime — there is zero performance overhead.
Why as unknown as T?

A direct cast from string to UserId fails because string does not have the _brand property. TypeScript rejects unsafe direct casts. The double cast through unknown is the standard escape hatch — it says "widen to the top type first, then narrow to the target." This is safe as long as the cast is hidden inside a validated constructor function.

TS
// Fails in strict mode — TypeScript considers this unsafe
const bad = "x" as UserId;

// Works — double cast through unknown
const good = "x" as unknown as UserId;
Tip
Always hide the cast inside a dedicated constructor function. Scattering as unknown as BrandedType throughout the codebase defeats the purpose of branding.
Adding Runtime Validation

The constructor is the ideal place to validate the raw value before branding it. Once the value passes through and gets its brand, the rest of the codebase can trust it completely:

TS
type Email = Brand<string, 'Email'>;
type Url   = Brand<string, 'Url'>;

function toEmail(raw: string): Email {
  if (!/^[^@]+@[^@]+.[^@]+$/.test(raw)) {
    throw new Error(`Invalid email: "${raw}"`);
  }
  return raw as unknown as Email;
}

function toUrl(raw: string): Url {
  try {
    new URL(raw); // built-in URL constructor validates structure
  } catch {
    throw new Error(`Invalid URL: "${raw}"`);
  }
  return raw as unknown as Url;
}

function sendNewsletter(email: Email, link: Url) {
  console.log(`Sending to ${email} with link ${link}`);
}

const email = toEmail("alice@example.com");
const link  = toUrl("https://example.com/news");

sendNewsletter(email, link);  // OK
sendNewsletter(link, email);  // Error: brands are incompatible
Real-World Brand: Money & Currency

Currency arithmetic is a classic source of bugs. Branded types make it impossible to accidentally add USD and EUR amounts together:

TS
type USD = Brand<number, 'USD'>;
type EUR = Brand<number, 'EUR'>;

const usd = (amount: number): USD => {
  if (amount < 0) throw new Error("Amount cannot be negative");
  return amount as unknown as USD;
};

const eur = (amount: number): EUR => {
  if (amount < 0) throw new Error("Amount cannot be negative");
  return amount as unknown as EUR;
};

function addUSD(a: USD, b: USD): USD {
  return (a + b) as unknown as USD;
}

const price    = usd(29.99);
const tax      = usd(2.40);
const shipping = eur(5.00);

const total = addUSD(price, tax);    // OK: USD + USD = USD
addUSD(price, shipping);              // Error: EUR is not assignable to USD
Opaque Types with Unique Symbols

For even stronger isolation, use a unique symbol as the brand key. No two unique symbol declarations are ever equal, so the brand cannot be accidentally replicated by another module:

TS
declare const __userId:  unique symbol;
declare const __orderId: unique symbol;

type UserId  = string & { readonly [__userId]:  never };
type OrderId = string & { readonly [__orderId]: never };

// Constructor usage is identical
function toUserId(id: string): UserId {
  return id as unknown as UserId;
}

function toOrderId(id: string): OrderId {
  return id as unknown as OrderId;
}
Note
The unique symbol approach is more verbose but provides the strongest guarantee — the brand cannot be recreated by name-matching in another file.
Brands vs. Wrapper Classes

An alternative to phantom brands is wrapping the primitive in a class. Classes use nominal (name-based) typing naturally, so two classes with the same shape are still different types:

TS
class UserId {
  constructor(readonly value: string) {
    if (!value) throw new Error('UserId cannot be empty');
  }
}

class OrderId {
  constructor(readonly value: string) {
    if (!value) throw new Error('OrderId cannot be empty');
  }
}

function getOrder(userId: UserId, orderId: OrderId) { /* ... */ }

// Correctly rejected — classes are nominally typed
getOrder(new OrderId("o-1"), new UserId("u-2")); // Error

Approach

Runtime cost

Primitive interop

Serialisation

Branded type

Zero — erased at compile time

Seamless — still a string/number

Works with JSON.stringify automatically

Wrapper class

Object allocation per value

Must unwrap with .value

Needs custom toJSON()

Plain type alias

Zero

Seamless but no safety

Works automatically

Parse-and-Brand at API Boundaries

Apply brands at the outermost layer (API responses, database rows, form inputs) and let them flow inward. The rest of your code can trust the brands without re-validating:

TS
interface RawApiUser {
  id: string;
  email: string;
  createdAt: string;
}

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

interface User {
  id:        UserId;
  email:     Email;
  createdAt: Date;
}

function parseUser(raw: RawApiUser): User {
  return {
    id:        raw.id    as unknown as UserId,
    email:     raw.email as unknown as Email,
    createdAt: new Date(raw.createdAt),
  };
}

async function fetchUser(id: UserId): Promise<User> {
  const res  = await fetch(`/api/users/${id}`);
  const raw: RawApiUser = await res.json();
  return parseUser(raw);
}

// Only a UserId can be passed — a plain string is rejected
fetchUser("user-42");                      // Error: string is not UserId
fetchUser(toUserId("user-42"));            // OK
Tip
This is the "parse, don't validate" principle applied to TypeScript. Validate once at the boundary, trust the brand everywhere inside.
Branded Types for Units of Measure

TS
type Metres    = Brand<number, 'Metres'>;
type Kilograms = Brand<number, 'Kilograms'>;
type Seconds   = Brand<number, 'Seconds'>;

const metres    = (n: number) => n as unknown as Metres;
const kilograms = (n: number) => n as unknown as Kilograms;
const seconds   = (n: number) => n as unknown as Seconds;

function speed(distance: Metres, time: Seconds): number {
  return distance / time;
}

speed(metres(100), seconds(9.58));    // OK
speed(metres(100), kilograms(70));    // Error: Kilograms is not Seconds
Summary
  • TypeScript is structurally typed — same shapes are interchangeable by default

  • Branded types add a phantom property to distinguish structurally identical types

  • The brand is erased at compile time — zero runtime overhead

  • Always encapsulate the cast inside a constructor function with optional validation

  • Use as unknown as T for the cast — never cast directly

  • Apply brands at API / DB boundaries and let them flow inward ("parse, don't validate")

  • Use unique symbol brands for maximum isolation across modules

  • Wrapper classes achieve nominal typing with runtime cost — choose based on your needs