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.
Design choices that matter for typescript branded types safety
TypeScript techniques in typescript branded types safety pay off when they encode invariants the compiler can check. Prefer types that make illegal states unrepresentable over sprawling any escapes.
Migration tactics
Enable strict incrementally: start with new packages, then tighten noImplicitAny, then strictNullChecks on legacy modules behind a burn-down list. Track error counts per package weekly.
Patterns that scale
Branded types for IDs, discriminated unions for results, and satisfies for config objects keep refactors safe. Utility types (Pick, Omit, ReturnType) reduce duplication without inventing a parallel type language.
Tooling
tsc --noEmit in CI, ESLint type-aware rules sparingly (they are slow), and API extractors for public packages. Generate types from OpenAPI/Zod when runtime validation must match compile-time types for typescript branded types safety.
Validation scenarios for typescript branded types safety
Before calling typescript branded types safety done, exercise these scenarios in a staging environment that mirrors production identity, data volume, and failure injection:
- Happy path with production-like payload sizes.
- Auth failure — expired token, missing scope, revoked session.
- Dependency down — timeout the primary collaborator; confirm degraded mode or clear error.
- Replay / duplicate — submit the same event or request twice; confirm idempotency.
- Rollback — disable the flag or revert the deploy; confirm state converges.
Capture traces for each scenario and store them next to the runbook for typescript branded types safety.
Ownership and interfaces
Name the producing and consuming teams for typescript branded types safety. Publish the API/event contract with versioning rules. If you need a breaking change, run dual-write or dual-read long enough for consumers to migrate. Silent breakages erode trust faster than slow features.
Cost, risk, and sequencing for typescript branded types safety
Sequence delivery so the riskiest assumption is tested first. If typescript branded types safety depends on a new data model, migrate a shadow path before cutting reads. If it depends on a new vendor, run a canary with synthetic traffic and a kill switch.
Budget engineering weeks for observability and docs — not only feature code. A system you cannot explain to on-call is not production-ready. Keep the Resources section pointed at primary specs so future changes track upstream behavior rather than outdated secondary summaries about typescript branded types safety.
| Gate | Evidence |
|---|---|
| Functional | Automated tests green on the critical path |
| Operable | Dashboard + alert + runbook linked |
| Secure | Threat model notes + authz tests |
| Reversible | Flag or rollback rehearsed |
Implementation detail #1 for typescript branded types safety
Focus area 1: timeouts and cancellation.
For typescript branded types safety, write an acceptance test that fails if this focus area regresses. Keep the test next to the production code, not in a separate unowned suite. Include a short comment linking to the incident or design note that motivated the check.
| Check | Expected |
|---|---|
| Focus 1 happy path | Pass |
| Focus 1 failure injection | Controlled error, no cascade |
| Focus 1 after rollback | Stable prior behavior |
Resources
- TypeScript Handbook: Type Compatibility
- Branded Types in TypeScript ( Egghead)
- Zod transform documentation
- type-fest Branded type utility
- Nominal typing wiki
Parse at boundary
Brand external ids when JSON enters your domain layer.
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 →