Branded Types for Safety
A production bug I traced last year came down to one line: transferFunds(fromAccount, toAccount, amount) where fromAccount and toAccount were both typed as string. A refactor swapped the arguments. TypeScript compiled cleanly. Money went to the wrong account. The fix wasn't a runtime check — it was branding AccountId so the compiler rejects transferFunds(toAccount, fromAccount, amount) before it ships. Branded types are how you get nominal typing in a structural type system, and they're one of the highest-leverage patterns in a TypeScript codebase.
The problem with structural typing
TypeScript's type system is structural: if two types have the same shape, they're compatible. This is usually great. It becomes a foot-gun when multiple domain concepts share the same shape:
type UserId = string;
type OrderId = string;
function getOrder(id: OrderId) { /* ... */ }
const userId: UserId = "usr_abc123";
getOrder(userId); // compiles fine — both are string
UserId and OrderId are documentation, not safety. The compiler sees two strings and allows the call.
Creating a branded type
Add a phantom property that exists only at the type level:
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;
The unique symbol ensures each brand is distinct. UserId and OrderId are both string & { readonly [__brand]: ... } but with different symbol values, making them incompatible.
Now the swapped-argument bug is a compile error:
function getOrder(id: OrderId) { /* ... */ }
const userId = "usr_abc123" as UserId;
getOrder(userId); // Error: UserId is not assignable to OrderId
Constructor functions: the gatekeeper
You can't cast arbitrary strings to branded types with as scattered through the codebase — that defeats the purpose. Use constructor functions that validate:
function UserId(value: string): UserId {
if (!value.startsWith("usr_")) {
throw new Error(`Invalid UserId: ${value}`);
}
return value as UserId;
}
function OrderId(value: string): OrderId {
if (!value.startsWith("ord_")) {
throw new Error(`Invalid OrderId: ${value}`);
}
return value as OrderId;
}
// At system boundaries (API parsing, DB reads)
const id = UserId(req.params.userId);
// Inside the domain, types flow safely
function getUser(id: UserId): User { /* ... */ }
Parse and brand at the boundary. Trust the brand everywhere else.
Real-world applications
Currency and units. Prevent adding dollars to cents:
type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;
function cents(value: number): Cents { return value as Cents; }
function dollars(value: number): Dollars { return value as Dollars; }
function toCents(d: Dollars): Cents {
return cents((d as number) * 100);
}
function addCents(a: Cents, b: Cents): Cents {
return cents((a as number) + (b as number));
}
addCents(price, tax); // OK
addCents(price, discountDollars); // Compile error
API route parameters. Express and Next.js give you string for every param. Brand at the handler boundary:
app.get("/users/:userId", (req, res) => {
const userId = UserId(req.params.userId);
const user = await getUser(userId);
// ...
});
Validated strings. Email, URL, slug — anything with a format constraint:
type Email = Brand<string, "Email">;
function Email(value: string): Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error(`Invalid email: ${value}`);
}
return value as Email;
}
Combining brands with Zod or io-ts
If you already validate with Zod, brand in the schema:
import { z } from "zod";
const UserIdSchema = z.string().startsWith("usr_").transform(
(val) => val as UserId
);
// Inferred type is UserId, not string
type Parsed = z.infer<typeof UserIdSchema>;
This keeps validation and branding in one place, which is where it belongs.
When not to brand
Brands add ceremony. Skip them when:
- The primitive is genuinely generic (a display name, a log message)
- There's only one use of that type in the codebase (no confusion possible)
- The value is ephemeral and never crosses function boundaries
Brand at domain boundaries where mixing values causes real bugs — IDs, money, units, tokens, and validated formats.
A reusable helper
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };
function brand<T, B extends string>(value: T): Brand<T, B> {
return value as Brand<T, B>;
}
// Usage
const id = brand<string, "UserId">("usr_123");
For larger codebases, centralize brand definitions in a types/brands.ts module and export constructors alongside the types. One file, one source of truth for what each brand means and how it's validated.
Common production mistakes
Teams get branded types safety wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
TypeScript patterns for branded types safety erode when any escapes during deadlines, generic constraints are loosened instead of modeling domain invariants, and strict mode is disabled file-by-file without a migration plan.
Debugging and triage workflow
When branded types safety misbehaves in production, work top-down instead of guessing:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.
Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.
Resources
- TypeScript Handbook: Type Compatibility
- Branded Types in TypeScript ( Egghead)
- Zod transform documentation
- type-fest Branded type utility
- Nominal typing wiki
Frequently asked questions
What is a branded type in TypeScript?
A branded type (also called a nominal or opaque type) is a primitive value — usually a string or number — tagged with a unique phantom property that TypeScript uses to distinguish it from other primitives of the same base type. UserId and OrderId are both strings at runtime, but TypeScript treats them as incompatible types at compile time, preventing you from passing one where the other is expected.
When should I use branded types instead of regular type aliases?
Use branded types when you have multiple values of the same primitive type that must never be confused — user IDs vs order IDs, dollars vs cents, meters vs feet, or authenticated vs raw session tokens. A plain type alias like type UserId = string provides documentation but zero compile-time safety. A brand prevents the accidental swap that a type alias cannot catch.
Do branded types have any runtime cost?
No. The brand exists only in the type system as a phantom property — it is never present at runtime. A branded string is still a string in JavaScript. The only runtime consideration is that you need explicit constructor functions to create branded values, which adds a thin layer of ceremony but also gives you a single place to validate input.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →