Chapter 13

Branded Types and Phantom Types: Blocking Invalid Operations

The Problem

TypeScript's type system is structural: two types are compatible if they have the same shape. This is usually an asset, but when two different concepts happen to share the same shape, structural typing leaves the compiler unable to help.

type UserId  = string;
type PostId  = string;
type OrderId = string;

function getPost(authorId: UserId, postId: PostId): Post {
  return db.posts.findOne({ author: authorId, id: postId });
}

const userId: UserId = "user_001";
const postId: PostId = "post_042";

getPost(postId, userId); // arguments completely swapped — no compiler error

All three type aliases are string. To TypeScript, they are identical. Branded types break this equivalence by injecting a compile-only marker into the type structure.


Branded Types: The Basic Pattern

Core idea: intersect string (or number) with an object type containing a field that will never exist at runtime — it exists only in the type checker.

// Base brand pattern
type UserId  = string & { readonly __brand: "UserId" };
type PostId  = string & { readonly __brand: "PostId" };
type OrderId = string & { readonly __brand: "OrderId" };

// These three types are now incompatible
declare const userId: UserId;
declare const postId: PostId;

const x: UserId = postId; // Compile error: Type 'PostId' is not assignable to type 'UserId'

The __brand field does not exist at runtime — the intersection type lives entirely in TypeScript's type-checking layer. There is no memory overhead, no runtime cost.


Safe Factory Functions

Using "abc" as UserId directly creates a branded value but bypasses the protection. The correct pattern is a factory function that validates before branding.

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

type UserId  = Brand<string, "UserId">;
type PostId  = Brand<string, "PostId">;
type OrderId = Brand<string, "OrderId">;
type Email   = Brand<string, "Email">;
type UUID    = Brand<string, "UUID">;

// Factory functions: validate, then brand
function makeUserId(raw: string): UserId {
  if (!raw.startsWith("user_")) {
    throw new Error(`Invalid UserId format: "${raw}"`);
  }
  return raw as UserId; // the only legitimate type assertion
}

function validateEmail(raw: string): Email {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(raw)) {
    throw new Error(`Invalid email: "${raw}"`);
  }
  return raw as Email;
}

function parseUUID(raw: string): UUID {
  const uuidRegex =
    /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
  if (!uuidRegex.test(raw)) {
    throw new Error(`Invalid UUID: "${raw}"`);
  }
  return raw as UUID;
}

// Consumer code: must go through the factory
const userId = makeUserId("user_001");    // UserId — validated
const email  = validateEmail("[email protected]");  // Email  — validated

function sendEmail(to: Email, subject: string): void {
  mailer.send(to, subject);
}

sendEmail("[email protected]", "spam"); // Compile error: string is not Email
sendEmail(email, "Welcome!");          // OK

Real Use Case: Validated Data Flow

Branded types shine when tracking whether data has been validated — separating raw user input from clean, validated values.

type RawInput        = string;
type ValidEmail      = Brand<string, "ValidEmail">;
type NormalizedEmail = Brand<string, "NormalizedEmail">;

// Validation layer: only valid email earns ValidEmail brand
function validateEmailInput(input: RawInput): ValidEmail | null {
  const trimmed = input.trim();
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return null;
  return trimmed as ValidEmail;
}

// Normalization layer: ValidEmail → lowercase NormalizedEmail
function normalizeEmail(email: ValidEmail): NormalizedEmail {
  return email.toLowerCase() as NormalizedEmail;
}

// Storage layer: only accepts NormalizedEmail
function saveUser(email: NormalizedEmail): Promise<void> {
  return db.users.insert({ email });
}

// Full registration flow
async function registerUser(formInput: RawInput): Promise<void> {
  const valid = validateEmailInput(formInput);
  if (!valid) throw new Error("Invalid email");
  const normalized = normalizeEmail(valid);
  await saveUser(normalized);
}

// All of these fail to compile:
saveUser("[email protected]");                     // string is not NormalizedEmail
saveUser(validateEmailInput("[email protected]")!);      // ValidEmail is not NormalizedEmail

Phantom Types

A phantom type has a type parameter that never appears in the runtime structure. It lives purely in the type layer and can express state machines, permission levels, and lifecycle constraints.

// State machine: database connection
declare const _connectionBrand: unique symbol;

type Connection<State extends "open" | "closed" | "error"> = {
  readonly [_connectionBrand]: State;
  readonly id: string;
};

// Factory: returns a connection in the specific state
function openConnection(url: string): Connection<"open"> {
  const conn = createRawConnection(url);
  return conn as unknown as Connection<"open">;
}

function closeConnection(conn: Connection<"open">): Connection<"closed"> {
  conn.close();
  return conn as unknown as Connection<"closed">;
}

// Only accepts an open connection
function query<T>(conn: Connection<"open">, sql: string): Promise<T> {
  return conn.execute(sql);
}

// Usage: the type system tracks state transitions
const conn   = openConnection("postgres://localhost/mydb"); // Connection<"open">
await query(conn, "SELECT 1");                               // OK

const closed = closeConnection(conn);           // Connection<"closed">
await query(closed, "SELECT 1");                // Compile error: Connection<"closed"> is not Connection<"open">

Preventing Units Confusion

Mixing physical or currency units is a real category of production bug. (Famously: NASA's Mars Climate Orbiter was lost because one team used imperial units and another used metric.)

type Meters    = Brand<number, "Meters">;
type Feet      = Brand<number, "Feet">;
type Kilograms = Brand<number, "Kilograms">;
type Pounds    = Brand<number, "Pounds">;

const meters = (n: number): Meters => n as Meters;
const feet   = (n: number): Feet   => n as Feet;

function calculateArea(width: Meters, height: Meters): Meters {
  return (width * height) as Meters;
}

const w = meters(5);
const h = feet(10);

calculateArea(w, h);          // Compile error: Feet is not assignable to Meters
calculateArea(w, meters(10)); // OK: 50 square meters

Real Project: Currency Amount Types

In e-commerce, USD and EUR amounts are both number, but mixing them causes financial errors.

type Currency = "USD" | "EUR" | "CNY" | "GBP";

type Money<C extends Currency> = {
  readonly amount: number;
  readonly currency: C;
  readonly __brand: C; // phantom tag
};

function money<C extends Currency>(amount: number, currency: C): Money<C> {
  if (amount < 0) throw new Error("Amount cannot be negative");
  return { amount, currency, __brand: currency };
}

// Addition: only same currency
function addMoney<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
  return money(a.amount + b.amount, a.currency);
}

// Explicit conversion: different currencies in, different currency out
function convertUSDtoEUR(usd: Money<"USD">, rate: number): Money<"EUR"> {
  return money(usd.amount * rate, "EUR");
}

// Display: accepts any currency
function formatMoney<C extends Currency>(m: Money<C>): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: m.currency,
  }).format(m.amount);
}

// Usage
const price      = money(99.99, "USD");   // Money<"USD">
const tax        = money(8.00,  "USD");   // Money<"USD">
const total      = addMoney(price, tax);  // Money<"USD"> — OK

const eurPrice   = money(89.99, "EUR");   // Money<"EUR">
addMoney(total, eurPrice);                // Compile error: Money<"EUR"> is not Money<"USD">

const converted  = convertUSDtoEUR(total, 0.92); // Money<"EUR"> — explicit conversion
console.log(formatMoney(total));      // "$107.99"
console.log(formatMoney(converted));  // "€99.35"

Combining with Result Types

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

type RawUserId   = string;
type ValidUserId = Brand<string, "ValidUserId">;

function parseUserId(raw: RawUserId): Result<ValidUserId, string> {
  if (!raw.match(/^user_[a-z0-9]{6,}$/)) {
    return { ok: false, error: `Invalid user ID format: "${raw}"` };
  }
  return { ok: true, value: raw as ValidUserId };
}

async function getUser(rawId: RawUserId): Promise<Result<User, string>> {
  const idResult = parseUserId(rawId);
  if (!idResult.ok) return idResult;

  const user = await db.users.findById(idResult.value); // ValidUserId — validated
  if (!user) return { ok: false, error: `User not found: ${rawId}` };
  return { ok: true, value: user };
}

Antipattern: Over-Branding

// Antipattern: branding every string even when there's no mixing risk
type FirstName     = Brand<string, "FirstName">;
type LastName      = Brand<string, "LastName">;
type StreetAddress = Brand<string, "StreetAddress">;
type CityName      = Brand<string, "CityName">;

function buildAddress(
  street: StreetAddress,
  city: CityName,
): string {
  return `${street}, ${city}`;
  // Who would accidentally pass CityName where StreetAddress is expected?
  // The brand adds complexity without reducing real risk.
}

Decision rule: Only brand a type when:

  1. Multiple values share the same primitive type in the same codebase
  2. They are plausibly passed to the same functions
  3. There is a realistic risk of passing the wrong one

UserId / PostId / OrderId — all string, all IDs, all passed to similar query functions: brand them. FirstName / LastName — rarely appear together as function arguments: probably not worth it.


Summary

Technique Best For Runtime Cost Complexity
Branded Types Same-primitive different concepts (IDs, currencies) Zero Low
Phantom Types State machines, permission levels Zero Medium
Validation-state brand Raw → Validated data pipelines Zero Low
Generic Money type Currency mixing prevention Minimal (object) Medium

What's Next

Chapter 14 tackles type-safe error handling. The catch clause in a try/catch is always unknown, and thrown errors are completely invisible in function signatures. The Result<T, E> pattern turns errors into return values, letting the compiler force you to handle every failure mode explicitly.

Rate this chapter
4.7  / 5  (23 ratings)

💬 Comments