TypeScript

Branded Types & Nominal Typing

35 min Lesson 26 of 40

Introduction to Branded Types

TypeScript uses structural typing by default, meaning types are compatible based on their structure rather than their names. However, there are cases where you need nominal typing behavior - distinguishing types based on their identity rather than just structure. Branded types provide this capability through a clever technique.

The Problem with Structural Typing

Consider this scenario:

type UserId = number; type ProductId = number; function getUser(id: UserId): string { return `User ${id}`; } const productId: ProductId = 42; getUser(productId); // No error! But this is semantically wrong

Both UserId and ProductId are structurally identical (just number), so TypeScript allows them to be used interchangeably. This can lead to subtle bugs.

Creating Branded Types

Branded types add a unique phantom property to distinguish types:

type Brand<K, T> = K & { __brand: T }; type UserId = Brand<number, 'UserId'>; type ProductId = Brand<number, 'ProductId'>; function getUser(id: UserId): string { return `User ${id}`; } // Type assertion needed to create branded values const userId = 42 as UserId; const productId = 42 as ProductId; getUser(userId); // ✓ OK getUser(productId); // ✗ Error: ProductId is not assignable to UserId
Note: The __brand property is a phantom type - it exists only at compile time and has no runtime representation. This means branded types have zero runtime overhead.

Factory Functions for Type Safety

Instead of type assertions everywhere, create factory functions:

type UserId = Brand<number, 'UserId'>; type Email = Brand<string, 'Email'>; function createUserId(id: number): UserId { if (id <= 0) { throw new Error('Invalid user ID'); } return id as UserId; } function createEmail(email: string): Email { if (!email.includes('@')) { throw new Error('Invalid email format'); } return email as Email; } // Now you have runtime validation + compile-time safety const user = createUserId(42); const email = createEmail('user@example.com');

Multi-Level Branding

You can create hierarchies of branded types:

type Id = Brand<number, 'Id'>; type UserId = Brand<Id, 'UserId'>; type AdminId = Brand<UserId, 'AdminId'>; function processId(id: Id): void { console.log(`Processing ID: ${id}`); } function processUser(id: UserId): void { console.log(`Processing user: ${id}`); } function processAdmin(id: AdminId): void { console.log(`Processing admin: ${id}`); } const adminId = 1 as AdminId; processAdmin(adminId); // ✓ OK processUser(adminId); // ✓ OK (AdminId extends UserId) processId(adminId); // ✓ OK (AdminId extends Id) const userId = 2 as UserId; processAdmin(userId); // ✗ Error: UserId not assignable to AdminId

Branded Types for Units of Measure

Prevent mixing incompatible units:

type Meters = Brand<number, 'Meters'>; type Kilometers = Brand<number, 'Kilometers'>; type Miles = Brand<number, 'Miles'>; const meters = (value: number) => value as Meters; const kilometers = (value: number) => value as Kilometers; const miles = (value: number) => value as Miles; function addMeters(a: Meters, b: Meters): Meters { return (a + b) as Meters; } function convertKmToMeters(km: Kilometers): Meters { return (km * 1000) as Meters; } const distance1 = meters(100); const distance2 = meters(50); const distance3 = kilometers(5); addMeters(distance1, distance2); // ✓ OK addMeters(distance1, convertKmToMeters(distance3)); // ✓ OK addMeters(distance1, distance3); // ✗ Error: Kilometers not assignable

Opaque Types Pattern

Create truly opaque types that hide implementation details:

// Declare the brand but never export the actual type declare const opaqueUserId: unique symbol; export type UserId = number & { readonly [opaqueUserId]: true }; // Only export factory and accessor functions export function createUserId(id: number): UserId { if (id <= 0) throw new Error('Invalid ID'); return id as UserId; } export function getUserIdValue(id: UserId): number { return id as number; } // Usage in another file import { UserId, createUserId, getUserIdValue } from './userId'; const id = createUserId(42); const rawValue = getUserIdValue(id); // Cannot create UserId directly - must use factory const invalid = 42 as UserId; // Still possible, but discouraged
Best Practice: Use opaque types for domain-specific types that should have controlled creation and access. This enforces proper encapsulation at the type level.

Branded Types with Validation

Combine branding with runtime validation:

type PositiveNumber = Brand<number, 'PositiveNumber'>; type Percentage = Brand<number, 'Percentage'>; type Age = Brand<number, 'Age'>; function assertPositive(n: number): asserts n is PositiveNumber { if (n <= 0) { throw new Error('Number must be positive'); } } function assertPercentage(n: number): asserts n is Percentage { if (n < 0 || n > 100) { throw new Error('Percentage must be between 0 and 100'); } } function assertAge(n: number): asserts n is Age { if (n < 0 || n > 150) { throw new Error('Invalid age'); } } function calculateDiscount(price: PositiveNumber, discount: Percentage): PositiveNumber { const result = price * (1 - discount / 100); return result as PositiveNumber; } const price = 100; assertPositive(price); // Now price is typed as PositiveNumber const discount = 20; assertPercentage(discount); // Now discount is typed as Percentage const finalPrice = calculateDiscount(price, discount);

Type-Safe String Patterns

Use branded types for validated string patterns:

type UUID = Brand<string, 'UUID'>; type HexColor = Brand<string, 'HexColor'>; type URL = Brand<string, 'URL'>; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const HEX_COLOR_REGEX = /^#[0-9a-f]{6}$/i; const URL_REGEX = /^https?:\/\/.+/; function createUUID(str: string): UUID { if (!UUID_REGEX.test(str)) { throw new Error('Invalid UUID format'); } return str as UUID; } function createHexColor(str: string): HexColor { if (!HEX_COLOR_REGEX.test(str)) { throw new Error('Invalid hex color format'); } return str as HexColor; } function createURL(str: string): URL { if (!URL_REGEX.test(str)) { throw new Error('Invalid URL format'); } return str as URL; } function setBackgroundColor(color: HexColor): void { document.body.style.backgroundColor = color; } const color = createHexColor('#ff5733'); setBackgroundColor(color); // ✓ Type-safe setBackgroundColor('#ff5733'); // ✗ Error: string not assignable to HexColor

Nominal Typing with Classes

Classes in TypeScript already have nominal typing characteristics:

class UserId { constructor(private value: number) { if (value <= 0) throw new Error('Invalid ID'); } getValue(): number { return this.value; } } class ProductId { constructor(private value: number) { if (value <= 0) throw new Error('Invalid ID'); } getValue(): number { return this.value; } } function getUser(id: UserId): string { return `User ${id.getValue()}`; } const userId = new UserId(42); const productId = new ProductId(42); getUser(userId); // ✓ OK getUser(productId); // ✗ Error: ProductId not assignable to UserId
Warning: Classes have runtime overhead (memory and performance) compared to branded types, which are purely compile-time constructs. Choose based on your needs.

Advanced Branding Techniques

Use unique symbols for stronger guarantees:

declare const userIdBrand: unique symbol; declare const productIdBrand: unique symbol; type UserId = number & { readonly [userIdBrand]: true }; type ProductId = number & { readonly [productIdBrand]: true }; // Helper for creating branded types type Branded<T, Brand extends symbol> = T & { readonly [K in Brand]: true }; declare const emailBrand: unique symbol; declare const urlBrand: unique symbol; type Email = Branded<string, typeof emailBrand>; type URL = Branded<string, typeof urlBrand>; function sendEmail(to: Email, subject: string, body: string): void { console.log(`Sending to ${to}: ${subject}`); } const email = 'user@example.com' as Email; sendEmail(email, 'Hello', 'World');

Real-World Example: Financial System

type Currency = 'USD' | 'EUR' | 'GBP'; declare const moneyBrand: unique symbol; type Money<C extends Currency> = { readonly amount: number; readonly currency: C; readonly [moneyBrand]: true; }; function createMoney<C extends Currency>( amount: number, currency: C ): Money<C> { if (amount < 0) throw new Error('Amount cannot be negative'); return { amount, currency, [moneyBrand]: true } as Money<C>; } function addMoney<C extends Currency>( a: Money<C>, b: Money<C> ): Money<C> { return createMoney(a.amount + b.amount, a.currency); } function convertCurrency<From extends Currency, To extends Currency>( money: Money<From>, toCurrency: To, rate: number ): Money<To> { return createMoney(money.amount * rate, toCurrency); } const usd100 = createMoney(100, 'USD'); const usd50 = createMoney(50, 'USD'); const eur80 = createMoney(80, 'EUR'); addMoney(usd100, usd50); // ✓ OK: both USD addMoney(usd100, eur80); // ✗ Error: cannot mix currencies // Must convert first const eur80AsUsd = convertCurrency(eur80, 'USD', 1.1); addMoney(usd100, eur80AsUsd); // ✓ OK: both USD now
Exercise:
  1. Create branded types for Temperature with units (Celsius, Fahrenheit, Kelvin)
  2. Implement conversion functions between units
  3. Create a WeatherReport type that ensures temperature units are consistent
  4. Add validation to prevent invalid temperatures (e.g., below absolute zero)