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:
🔖 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:
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
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:
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.
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.
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.
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.