skip to content
Theme Test

Branded Types in TypeScript

Table of Contents

The Problem

Consider this function signature:

function transferMoney(from: string, to: string, amount: number): void

Three parameters, all primitives. The type system will happily let you call transferMoney(amount, to, from) with the arguments in the wrong order. TypeScript sees string, string, number and shrugs.

This is the primitive obsession problem. When every ID is a string and every quantity is a number, the type system can’t protect you from mixing them up.

Branded Types

The fix is to make AccountId and TransactionAmount distinct types that happen to be backed by primitives:

type Brand<T, B> = T & { readonly __brand: B };
type AccountId = Brand<string, "AccountId">;
type USD = Brand<number, "USD">;
function transferMoney(from: AccountId, to: AccountId, amount: USD): void {
// implementation
}

Now transferMoney("abc", "def", 100) is a type error. You need to explicitly construct the branded values:

function toAccountId(id: string): AccountId {
if (!id.match(/^acct_[a-z0-9]{16}$/)) {
throw new Error(`Invalid account ID: ${id}`);
}
return id as AccountId;
}
function toUSD(cents: number): USD {
if (!Number.isInteger(cents) || cents < 0) {
throw new Error(`Invalid amount: ${cents}`);
}
return cents as USD;
}

The as cast is the only runtime “cost” — and it compiles away entirely. The __brand property never exists at runtime. It’s a phantom type that only lives in the type checker’s head.

Where This Shines

Database IDs

Every table gets its own ID type. UserId and PostId are never interchangeable, even though they’re both UUIDs underneath.

type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
// This is now a type error — exactly what we want
function getPost(id: PostId): Post { /* ... */ }
getPost(currentUser.id); // Error: UserId is not assignable to PostId

Units of Measurement

Meters and feet, Celsius and Fahrenheit, pixels and rems. Branded types prevent the kind of unit confusion that crashed a Mars orbiter.

type Meters = Brand<number, "Meters">;
type Feet = Brand<number, "Feet">;
function metersToFeet(m: Meters): Feet {
return (m * 3.28084) as Feet;
}

Validated Strings

Email addresses, URLs, slugs — strings that have been validated and are known to be well-formed:

type Email = Brand<string, "Email">;
type Slug = Brand<string, "Slug">;
function sendEmail(to: Email, subject: string): void { /* ... */ }
// Forces validation at the boundary
function parseEmail(input: string): Email | null {
return isValidEmail(input) ? (input as Email) : null;
}

Tradeoffs

Benefits:

  • Zero runtime overhead (brands are erased at compile time)
  • Catches a whole class of bugs at the type level
  • Self-documenting — function signatures tell you what they actually expect
  • Validation happens once at the boundary, not scattered through the codebase

Costs:

  • Slightly more verbose at construction sites
  • The as casts can feel like you’re lying to the compiler (you’re not — you’re informing it)
  • Libraries won’t return your branded types — you need adapter layers at the edges
  • Coworkers will ask “what is this __brand thing” exactly once

The pattern works best when you have clear domain boundaries. If every string in your app needs to be branded, you’ve probably gone too far. Start with the types that get mixed up in practice — IDs, money, and validated formats.