Branded Types
Add nominal typing to TypeScript's structural system to prevent mixing semantically distinct values.
TypeScript's type system is structural — two types are compatible if they have the same shape, regardless of their names. This is powerful and flexible, but it has a downside: values that mean different things can be used interchangeably if they have the same structure.
Consider:
type UserId = string;
type OrderId = string;
function getUser(id: UserId): void {}
function getOrder(id: OrderId): void {}
const userId: UserId = "user-123";
const orderId: OrderId = "order-456";
getUser(orderId); // Oops! No error — both are just strings.TypeScript can't tell them apart because they're structurally identical. This chapter introduces branded types (also called opaque types or nominal types) — a technique to tag values with phantom properties that make them incompatible at compile time, even though they're the same at runtime.
The Problem: Structural Typing's Blind Spot
Let's say you're building a financial application. You have Cents (integer amounts, e.g., $12.34 = 1234 cents) and Dollars (floating-point amounts). Both are number at runtime, but mixing them is a bug:
The code compiles, but displayPrice expects cents (1234), not dollars (12.34). The output is nonsense.
Solution: Branding with a Phantom Property
A brand is a property that exists only in the type system, never at runtime. We add it by intersecting with a unique tag:
type Cents = number & { readonly __brand: "Cents" };
type Dollars = number & { readonly __brand: "Dollars" };Now Cents and Dollars are no longer compatible, even though they're both number at runtime. The __brand property is a phantom — it doesn't exist on the value, but TypeScript sees it in the type.
Here's how to create branded values:
We use smart constructors (toCents, toDollars) to produce branded values. Inside the constructor, we cast the plain number to the branded type with as. Outside, users can't accidentally pass a plain number — they must go through the constructor.
Using unique symbol for Extra Safety
String brands like "Cents" work, but they're not truly unique. If two libraries use the same brand name, they collide. For maximum safety, use unique symbol:
A unique symbol is a type that's distinct from all others — even if two modules declare the same name, their symbols are different. This prevents accidental collisions.
The declare const trick defines the symbol's type without creating a runtime value. The brand exists only in the type system.
Practical Example: User IDs and Order IDs
Let's apply branding to IDs in a typical application:
Now, if you accidentally pass an OrderId to getUser, the compiler catches it. The brand forces you to use the right ID type.
Combining Brands with Validation
Smart constructors are also the perfect place to validate input. You can ensure that branded values are always valid:
Because Email can only be created through createEmail, you know that any Email value has passed validation. This is a powerful pattern: make illegal states unrepresentable.
Multi-File Example: Branded Types Across Modules
Let's organize branded types and their constructors across multiple files for better maintainability.
Each module exports:
- The branded type (e.g.,
UserId). - The smart constructor (e.g.,
createUserId).
Consumers import both and can't accidentally mix IDs. The brand symbols are declared with declare const, so they exist only in the type system — no runtime overhead.
When to Use Branded Types
Brands are most valuable when:
- Semantically distinct values share the same primitive type: user IDs vs. order IDs, cents vs. dollars, validated vs. unvalidated strings.
- Accidental mixing is easy and costly: in large codebases, passing the wrong ID or unit can cause silent bugs.
- You want to enforce validation: by making the brand the only way to construct the value, you guarantee validity.
Brands are not necessary for:
- Types that are structurally distinct already (e.g.,
{ id: string }vs.{ name: string }). - Simple scripts or prototypes where the overhead isn't worth it.
Challenge: Implement Branded Temperature Units
Create two branded types, Celsius and Fahrenheit, both based on number. Implement smart constructors toCelsius and toFahrenheit, and a conversion function celsiusToFahrenheit that accepts Celsius and returns Fahrenheit.
Conversion formula: F = C × 9/5 + 32
The compiler should prevent passing Fahrenheit where Celsius is expected, and vice versa.
Challenge: Multi-File Branded Email
Split the branded Email type and its validation into separate files. In email.ts, define the branded type and a createEmail function that validates the input (must contain @). In mailer.ts, define a sendEmail function that accepts Email. In main.ts, create an email and send it.
The compiler should prevent passing a plain string to sendEmail.
Multiple Choice: Branding Mechanism
What makes branded types incompatible with their underlying primitives?
The smart constructor function
The intersection with a phantom property
The as cast inside the constructor
Multiple Choice: unique symbol vs. String Brands
Why prefer unique symbol over string literals for brands?
String brands are less type-safe
unique symbol prevents accidental collisions across modules
unique symbol has better runtime performance
Summary
You've learned how to:
- Recognize the limitations of structural typing for semantically distinct values.
- Create branded types with phantom properties (intersection types).
- Use smart constructors to enforce that branded values are created correctly.
- Prefer
unique symbolfor collision-proof brands. - Validate inputs at construction time, ensuring all branded values are valid.
- Organize branded types across modules for maintainability.
Brands add a thin layer of nominal typing to TypeScript's structural world. They prevent entire classes of bugs (mixing IDs, units, or invalid data) at zero runtime cost. In the next chapter, we'll scale up to API modeling, where brands and other type-level contracts ensure correctness across module and service boundaries.
Next: API Modeling & Contracts — treating types as contracts between modules and services.