TypeScript's type system is powerful, but it has a blind spot: two different things wearing the same type face. A userId and an orderId are both strings, so nothing stops you from passing one where the other belongs. Branded types solve this by adding a phantom property that makes types distinct at compile time, catching bugs that would slip past traditional type checking.
The problem — userId and orderId are both strings. Nothing stops you swapping them.
I hit this bug in production three years ago while building a ride-hailing platform. A function expected a driverId, received a passengerId, and silently used it anyway. The type system saw two strings and said "looks good to me." The feature worked until someone queried the wrong database table, returning stale data to the wrong person.
This is the core issue: semantic difference doesn't equal type difference. In a traditional TypeScript setup, both values are string:
type UserId = string;
type OrderId = string;
function fetchUser(id: UserId) {
// query user table
}
const userId: UserId = "user-123";
const orderId: OrderId = "order-456";
fetchUser(orderId); // ✅ TypeScript allows this. Should not.At runtime, the function receives "order-456" instead of "user-123". The query might return nothing, return wrong data, or fail in a subtle way hours later. Your tests pass because they don't catch type mismatches—only runtime behavior. You need the compiler to reject this before it ships.
Brands — Add a phantom property to make types distinct.
A branded type uses a phantom property: a field that exists only for the type system, never at runtime. It's invisible to the JavaScript engine but makes types incompatible at compile time.
Here's how to create one:
type Brand = T & { readonly __brand: B };
type UserId = Brand;
type OrderId = Brand;
function fetchUser(id: UserId) {
// query user table
}
const userId: UserId = "user-123" as UserId;
const orderId: OrderId = "order-456" as OrderId;
fetchUser(userId); // ✅ OK
fetchUser(orderId); // ❌ Type error: OrderId is not assignable to UserIdThe __brand property is a string literal unique to each type. TypeScript uses it to distinguish UserId from OrderId, even though both are strings underneath. The as UserId cast tells TypeScript "trust me, this is valid," but this is where validation should live—not at the call site.
The intersection syntax T & { readonly __brand: B } means "a string with an invisible UserId marker." The property is readonly to prevent accidental modification. It never appears in runtime objects; it's pure type-level magic.
Create through functions — Only validated constructors produce branded values.
Casting everywhere defeats the purpose. Instead, use constructor functions that validate first, then brand the result. This ensures only legitimate values get the brand.
type UserId = Brand;
type OrderId = Brand;
function createUserId(raw: string): UserId {
if (!raw || raw.length === 0) {
throw new Error("UserId cannot be empty");
}
return raw as UserId;
}
function createOrderId(raw: string): OrderId {
if (!raw || raw.length === 0) {
throw new Error("OrderId cannot be empty");
}
return raw as OrderId;
}
function fetchUser(id: UserId) {
console.log("Fetching user:", id);
}
// ✅ Safe paths
const userId = createUserId("user-123");
fetchUser(userId);
// ❌ Caught early
try {
const badId = createUserId("");
} catch (e) {
console.error("Invalid ID:", e.message);
}
// ❌ Type error
const orderId = createOrderId("order-456");
fetchUser(orderId); // Type error: OrderId is not assignable to UserIdNow only code that goes through createUserId can pass a UserId to fetchUser. You control the validation logic. If you parse IDs from an API response, the parsing function becomes your single source of truth. Bad data never gets branded.
In a real system, parsing might be more complex:
function createUserIdFromApi(raw: unknown): UserId {
if (typeof raw !== "string") {
throw new Error("UserId must be a string");
}
if (!raw.startsWith("usr_")) {
throw new Error("UserId must start with usr_");
}
if (raw.length > 50) {
throw new Error("UserId too long");
}
return raw as UserId;
}
// Only IDs matching the pattern become UserId
const id = createUserIdFromApi(JSON.parse(apiResponse).userId);This pattern scales. Every branded type gets its own constructor with domain-specific rules.
Use for money too — Cents vs rupees bugs disappear.
I've seen money bugs destroy features. A price stored in cents gets added to one in rupees. A transaction amount is multiplied when it should be converted. The number type doesn't capture intent, so these errors hide in business logic.
Branded types catch this:
type CentsAmount = Brand;
type RupeesAmount = Brand;
function createCentsAmount(value: number): CentsAmount {
if (value < 0 || !Number.isInteger(value)) {
throw new Error("Amount in cents must be a non-negative integer");
}
return value as CentsAmount;
}
function createRupeesAmount(value: number): RupeesAmount {
if (value < 0 || !Number.isInteger(value)) {
throw new Error("Amount in rupees must be a non-negative integer");
}
return value as RupeesAmount;
}
function calculateTax(amount: CentsAmount): CentsAmount {
return createCentsAmount(Math.floor(amount * 0.18));
}
function chargeCard(cents: CentsAmount) {
console.log("Charging:", cents, "cents");
}
const priceInCents = createCentsAmount(5000); // 50 USD
const taxInRupees = createRupeesAmount(900); // 900 INR
calculateTax(priceInCents); // ✅ OK, returns CentsAmount
calculateTax(taxInRupees); // ❌ Type error
chargeCard(priceInCents); // ✅ OK
chargeCard(taxInRupees); // ❌ Type errorWith branded types, you can't accidentally treat rupees as cents. You can't add them, compare them, or pass them to functions expecting the other unit. The compiler forces you to convert explicitly:
function convertRupeesToCents(rupees: RupeesAmount): CentsAmount {
// 1 USD = 82.5 INR, so 1 rupee = ~1.2 cents
return createCentsAmount(Math.round(rupees * 1.2));
}
const chargeAmount = convertRupeesToCents(taxInRupees);
chargeCard(chargeAmount); // ✅ Now it works, after explicit conversionFor a trading platform processing orders in different currencies, this pattern is essential. I've used it to prevent millions in reconciliation headaches.
Common mistakes
- Casting instead of constructing: Writing
"user-123" as UserIdeverywhere bypasses validation. Create branded values only through constructor functions. - Sharing a brand name: If two unrelated types use the same brand name, TypeScript treats them as compatible. Use unique brand identifiers.
- Forgetting readonly: Make the brand property
readonlyto prevent accidental reassignment or tampering. - Over-branding: Not every string needs a brand. Use them for domain concepts (IDs, money, units) where mixing types causes real bugs.
- Loose validation: A constructor that accepts almost anything defeats the purpose. Validate domain constraints: length, format, range, prefix.
Key takeaways
- Branded types add a phantom property to make semantically different values type-incompatible, catching bugs at compile time instead of production.
- Always create branded values through constructor functions with validation, not through direct casts.
- Use brands for domain concepts: IDs, monetary amounts, units, and any value where type confusion causes real bugs.
- The brand property exists only in the type system; it has zero runtime overhead.
- This pattern scales from simple string IDs to complex money and unit handling in production platforms.






Comments
Loading comments…