Branded Types & Phantom Types

Advanced TypeScript — Branded Types & Phantom Types
Advanced TypeScript
Course 4 · Chapter 5 · Branded Types & Phantom Types

🏷️ Branded Types & Phantom Types

UserId and OrderId are both just string at runtime — nothing stops you from passing one where the other belongs. This chapter covers the technique that fixes that: attaching a compile-time-only "brand" to a type so two values with identical runtime shape are still treated as genuinely different types, plus phantom type parameters that track state without appearing in the value at all.

The Problem: Structurally Identical, Semantically Different

TypeScript's structural typing (the same feature that makes duck typing work) has a downside here — any string satisfies any other type that's just string:

type UserId = string; type OrderId = string; function getUser(id: UserId) { /* ... */ } const orderId: OrderId = "order-123"; getUser(orderId); // ❌ should be an error, but compiles fine — both are just `string`

🔖 Branded Types

A brand adds a compile-time-only tag to a type via an intersection — the tag never exists at runtime, but it makes two otherwise-identical types incompatible with each other:

type Brand<T, TBrand extends string> = T & { readonly __brand: TBrand }; type UserId = Brand<string, "UserId">; type OrderId = Brand<string, "OrderId">; function getUser(id: UserId) { /* ... */ } const orderId = "order-123" as OrderId; // getUser(orderId); // ❌ NOW a real compile error: OrderId isn't assignable to UserId

Zero Runtime Cost

__brand never actually exists on the value — it's purely a type-checker fiction, so branded values are identical strings/numbers at runtime.

Nominal, Not Structural

Brands are how TypeScript approximates the nominal typing that languages like Java/C# have natively — "same shape" is no longer "same type."

Smart Constructors: The Only Legitimate Way In

A brand is only meaningful if creating one requires passing a real check — the same "prove it, don't just assert it" principle from Chapter 4's guards and Course 3's zod validation:

A Validated Email Brand

import { z } from "zod"; type Email = Brand<string, "Email">; const EmailSchema = z.string().email(); function createEmail(input: string): Email { return EmailSchema.parse(input) as Email; // the ONLY place `as Email` should ever appear } function sendWelcomeEmail(to: Email) { /* ... */ } const email = createEmail("jane@example.com"); // throws if invalid, otherwise genuinely an Email sendWelcomeEmail(email); // ✅ // sendWelcomeEmail("plain string"); // ❌ a raw string is not an Email — must go through createEmail()

Once createEmail is the only function allowed to produce an Email, receiving a value typed Email anywhere else in the codebase is a guarantee it was validated — not just a hope.

👻 Phantom Types

A phantom type parameter never appears in the value at all — it exists purely so the type system can track a state or unit that the runtime value doesn't carry:

interface Query<State extends "unvalidated" | "validated"> { sql: string; // `State` never appears below — it's phantom, purely a compile-time tag } function buildQuery(sql: string): Query<"unvalidated"> { return { sql }; } function validateQuery(query: Query<"unvalidated">): Query<"validated"> { // ... real validation logic (no SQL injection risk, known tables, etc.) ... return query as Query<"validated">; } function execute(query: Query<"validated">) { /* run it */ } const raw = buildQuery("SELECT * FROM users"); // execute(raw); // ❌ Query<"unvalidated"> is not Query<"validated"> execute(validateQuery(raw)); // ✅ must pass through validateQuery first

Units of Measure

Distance<"meters"> vs Distance<"feet"> — the number is identical at runtime; the phantom parameter stops you from adding meters to feet by accident.

Workflow States

Query<"unvalidated"> vs Query<"validated"> — the compiler enforces a step happened, without the value itself needing to record that it happened.

💻 Coding Challenges

Challenge 1: Brand Two ID Types

Define UserId and ProductId as branded string types, plus two smart constructors toUserId/toProductId that validate a non-empty string, then write a function that only accepts a UserId and demonstrate that a ProductId can't be passed to it.

Goal: Practice the brand + smart constructor pattern for preventing ID mix-ups.

→ Solution

Challenge 2: Brand a Validated Password

Define a StrongPassword branded type and a toStrongPassword(input: string): StrongPassword smart constructor that throws unless the input is at least 12 characters and contains a digit.

Goal: Practice using a brand to enforce a validation rule has already run, similar to Chapter 4's Email example but with custom logic instead of zod.

→ Solution

Challenge 3: Phantom-Type a Units System

Write a Distance<Unit extends "meters" | "feet"> type wrapping a plain number, plus meters(n: number)/feet(n: number) constructors and an addDistance function that only accepts two Distance values of the same unit.

Goal: Practice a phantom type parameter that exists purely to prevent mixing incompatible units, with no runtime representation of the unit itself.

→ Solution

⚠️ Gotcha: A Brand Is Only As Strong As Its Constructor

Branding adds zero runtime safety by itself — "anything" as UserId compiles without complaint anywhere in the codebase, silently defeating the entire point. The safety comes entirely from discipline: only ever producing a branded value through its one smart constructor, and treating a bare as Brand cast outside that constructor as a code-review red flag, exactly like the "guard that lies" warning from Chapter 4.

🎯 What's Next

Branding stamps a single compile-time tag onto a value — the next chapter goes further, using types to actually compute: Type-Level Programming, where the type system itself becomes a small functional language for building type-safe builders and DSL-like patterns.