Branded Types and Phantom Types in TypeScript
Last month I tracked down a production bug that took three hours to find and three seconds to fix. A function expected a userId but received an orderId. Both were strings. TypeScript was perfectly happy. The database was not.
The fix was a one-line swap. But the real fix — the one that prevents this entire category of bugs — took me five minutes and zero runtime cost. It's called branded types, and once you see the technique, you'll wonder why every TypeScript codebase doesn't use it.
The Problem: Primitive Obsession
TypeScript's type system is structural. If two values have the same shape, they're interchangeable. That's usually a feature. But for domain types, it's a trap.
function chargeOrder(orderId: string, userId: string, amount: number) {
// ...
}
const orderId = "ord_abc123";
const userId = "usr_xyz789";
// Swapped arguments — TypeScript says this is fine
chargeOrder(userId, orderId, 49.99);No red squiggles. No compiler error. Just a silent bug that charges the wrong user for the wrong order.
Here are a few more that TypeScript won't catch:
// Mixing up currencies
const priceUSD = 100;
const priceEUR = 85;
const total = priceUSD + priceEUR; // Meaningless number, no error
// Milliseconds vs seconds
const createdAt = Date.now(); // milliseconds
const expiresIn = 3600; // seconds
const expiresAt = createdAt + expiresIn; // Off by 1000x, no error
// Raw string as "validated" email
function sendEmail(to: string) { /* ... */ }
sendEmail("not-an-email"); // No complaintEvery one of these is a string or number. Every one of these is a potential production incident. The type system should be catching these, but we're giving it nothing to work with.
Branded Types
The idea is simple: attach a phantom property to a primitive type that only exists at the type level. It never shows up at runtime — no extra memory, no serialization issues, no performance cost.
// The brand utility type
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
type Branded<T, B> = T & Brand<B>;Now we can create distinct types that are structurally incompatible:
type UserId = Branded<string, "UserId">;
type OrderId = Branded<string, "OrderId">;
type ProductId = Branded<string, "ProductId">;These are all strings at runtime. But at the type level, UserId and OrderId are different types. You can't pass one where the other is expected.
We need a helper to create branded values:
function userId(id: string): UserId {
return id as UserId;
}
function orderId(id: string): OrderId {
return id as OrderId;
}Now the original bug becomes a compile-time error:
function chargeOrder(orderId: OrderId, userId: UserId, amount: number) {
// ...
}
const order = orderId("ord_abc123");
const user = userId("usr_xyz789");
// Swapped arguments — TypeScript catches this now
chargeOrder(user, order, 49.99);
// ^^^^ ^^^^^
// Error: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'The bug that took three hours to find in production is now a red squiggle before you even save the file.
Practical Examples
Currency Safety
Adding dollars to euros is nonsensical. Let's make the compiler agree:
type USD = Branded<number, "USD">;
type EUR = Branded<number, "EUR">;
function usd(amount: number): USD {
return amount as USD;
}
function eur(amount: number): EUR {
return amount as EUR;
}
const price = usd(99.99);
const shipping = usd(5.0);
const tax = eur(8.5);
// This works — same currency
const subtotal: USD = (price + shipping) as USD;
// This fails — different currencies
const total: USD = (price + tax) as USD;
// ^^^ Error: can't add EUR to USDTo convert between currencies, you'd write an explicit function that makes the conversion visible:
function eurToUsd(amount: EUR, rate: number): USD {
return (amount * rate) as USD;
}Validated Email
A raw string shouldn't be treated the same as a validated email address:
type ValidEmail = Branded<string, "ValidEmail">;
function validateEmail(input: string): ValidEmail | null {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(input)) return null;
return input as ValidEmail;
}
function sendWelcomeEmail(to: ValidEmail) {
// We know this is a valid email — the type guarantees it
}
// Can't bypass validation
sendWelcomeEmail("not-an-email");
// Error: Argument of type 'string' is not assignable to parameter of type 'ValidEmail'
// Must go through the validator
const email = validateEmail("max@example.com");
if (email) {
sendWelcomeEmail(email); // Works
}Timestamps
Mixing up seconds and milliseconds is one of the most common time-related bugs:
type UnixSeconds = Branded<number, "UnixSeconds">;
type UnixMillis = Branded<number, "UnixMillis">;
function nowSeconds(): UnixSeconds {
return Math.floor(Date.now() / 1000) as UnixSeconds;
}
function nowMillis(): UnixMillis {
return Date.now() as UnixMillis;
}
function secondsToMillis(s: UnixSeconds): UnixMillis {
return (s * 1000) as UnixMillis;
}
function expiresAt(from: UnixSeconds, ttlSeconds: UnixSeconds): UnixSeconds {
return (from + ttlSeconds) as UnixSeconds;
}
// Can't accidentally mix them
const created = nowMillis();
const ttl = nowSeconds();
const expires = expiresAt(created, ttl);
// ^^^^^^^
// Error: 'UnixMillis' is not assignable to 'UnixSeconds'Phantom Types
Branded types tag primitive values. Phantom types take the idea further — they encode state into generic type parameters that exist only at the type level, never at runtime.
The classic use case is a state machine. Consider a document workflow where only published documents can be shared:
type Draft = "draft";
type Published = "published";
type Archived = "archived";
interface Document<State extends string> {
id: string;
title: string;
content: string;
// State is never stored as a value — it only exists in the type
}
function createDocument(title: string, content: string): Document<Draft> {
return { id: crypto.randomUUID(), title, content };
}
function publish(doc: Document<Draft>): Document<Published> {
// In reality, this would call an API, update a database, etc.
return { ...doc };
}
function archive(doc: Document<Published>): Document<Archived> {
return { ...doc };
}
function share(doc: Document<Published>): string {
return `https://docs.example.com/${doc.id}`;
}Now the state machine is enforced at compile time:
const draft = createDocument("Q1 Report", "Revenue grew...");
// Can't share a draft
share(draft);
// Error: Document<"draft"> is not assignable to Document<"published">
// Must publish first
const published = publish(draft);
share(published); // Works
// Can't publish something that's already published
publish(published);
// Error: Document<"published"> is not assignable to Document<"draft">
// Can't go back from archived to published
const archived = archive(published);
publish(archived);
// Error: Document<"archived"> is not assignable to Document<"draft">The state transitions are explicit and enforced. You literally cannot call share with a draft document. The compiler won't let you.
Builder Pattern with Phantom Types
One of the most powerful applications of phantom types is the type-safe builder pattern. If you've read the design patterns series, you know the Builder pattern lets you construct complex objects step by step. Phantom types let you enforce that required steps are completed — at compile time.
The trick is encoding which fields have been set into the type parameter:
interface BuilderState {
hasName: boolean;
hasEmail: boolean;
hasRole: boolean;
}
interface UserBuilder<S extends BuilderState> {
name: string | undefined;
email: string | undefined;
role: string | undefined;
}
type Initial = { hasName: false; hasEmail: false; hasRole: false };
function createUserBuilder(): UserBuilder<Initial> {
return { name: undefined, email: undefined, role: undefined };
}
function setName<S extends BuilderState>(
builder: UserBuilder<S>,
name: string
): UserBuilder<S & { hasName: true }> {
return { ...builder, name };
}
function setEmail<S extends BuilderState>(
builder: UserBuilder<S>,
email: string
): UserBuilder<S & { hasEmail: true }> {
return { ...builder, email };
}
function setRole<S extends BuilderState>(
builder: UserBuilder<S>,
role: string
): UserBuilder<S & { hasRole: true }> {
return { ...builder, role };
}
interface User {
name: string;
email: string;
role: string;
}
// build() only accepts a builder where all fields are set
function build(
builder: UserBuilder<{ hasName: true; hasEmail: true; hasRole: true }>
): User {
return {
name: builder.name!,
email: builder.email!,
role: builder.role!,
};
}Now the compiler enforces completeness:
const incomplete = setName(createUserBuilder(), "Max");
// Can't build — missing email and role
build(incomplete);
// Error: hasEmail: false is not assignable to hasEmail: true
const complete = setRole(
setEmail(
setName(createUserBuilder(), "Max"),
"max@example.com"
),
"admin"
);
build(complete); // Works — all fields setOrder doesn't matter. You can set fields in any sequence. But you can't call build() until everything is there. No runtime checks needed.
The Validation Bridge
Branded types are only as strong as their entry points. If you scatter as UserId casts everywhere, you've defeated the purpose. The key is to funnel all branded value creation through validator functions.
Here's the pattern with Zod:
import { z } from "zod";
type UserId = Branded<string, "UserId">;
type ValidEmail = Branded<string, "ValidEmail">;
const UserIdSchema = z
.string()
.regex(/^usr_[a-zA-Z0-9]{8,}$/, "Invalid user ID format")
.transform((val): UserId => val as UserId);
const ValidEmailSchema = z
.string()
.email("Invalid email address")
.transform((val): ValidEmail => val as ValidEmail);
// Usage — the only way to create a UserId is through the schema
const result = UserIdSchema.safeParse("usr_abc12345");
if (result.success) {
const id: UserId = result.data; // Branded and validated
}This pattern works beautifully with API boundaries. Parse your inputs at the edge, brand them, and every function downstream gets both type safety and validation guarantees:
const CreateUserInput = z.object({
email: ValidEmailSchema,
name: z.string().min(1),
});
type CreateUserInput = z.infer<typeof CreateUserInput>;
// { email: ValidEmail; name: string }
async function createUser(input: CreateUserInput) {
// input.email is ValidEmail — no need to re-validate
sendWelcomeEmail(input.email); // Type-safe, no cast needed
}The as cast only appears once — inside the Zod transform. Every downstream consumer gets branded types for free.
Tradeoffs
Branded types aren't always the right call. Here's when they shine and when they're overkill.
Use branded types when:
- You have multiple values of the same primitive type that must not be mixed (IDs, currencies, units)
- Incorrect usage causes silent bugs that are hard to trace (the
userId/orderIdswap) - You have validation invariants you want to encode in the type (validated emails, positive numbers, non-empty strings)
- You're building a library or shared API where consumers might misuse your types
Skip them when:
- A type is used in exactly one place with no risk of confusion
- You're prototyping and the domain model is still changing daily
- The team isn't bought in — branded types add friction, and if people just scatter
ascasts to silence errors, you've gained nothing but noise
The honest cost:
- More
ascasts at creation boundaries (mitigate with Zod transforms) - Slightly more verbose function signatures
- New team members need to understand the pattern
- Serialization and deserialization (JSON doesn't know about brands) requires explicit handling
The runtime cost is zero — brands are erased during compilation. The cognitive cost is real but pays for itself the first time the compiler catches a bug that would've hit production.
If you take one thing from this article: start with your ID types. Replace string with UserId, OrderId, ProductId. It takes five minutes, costs nothing at runtime, and the next time someone swaps two arguments, they'll get a red squiggle instead of a 3 AM page.
Comments
No comments yet. Be the first to comment!