Branded Types
Reviewed & published by Brayan K
A branded type adds a phantom "brand" to a base type — like string & { __brand: "UserId" } — so TypeScript treats two otherwise-identical types as incompatible, simulating nominal typing on top of its structural system.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Add a phantom brand to make same-shaped types distinct
- Why TypeScript's structural typing lets ids and units mix
- Prevent mixing user ids with order ids (and meters with feet)
- Write smart constructors that validate then brand
- Trust a branded value as proof that validation passed
- Understand that brands are free — erased at runtime
💡 Real-World Analogy
Two banknotes can look identical, but a watermark proves which is genuine and which currency it is. A brand is that watermark for your types: a user id and an order id are both "just strings," but the invisible brand stamps each with its true purpose. The watermark isn't part of the paper's value — and likewise the brand vanishes at runtime — yet it stops you spending dollars as if they were euros.
1. Adding a Phantom Brand
TypeScript is structural, so a UserId that's "just a string" is interchangeable with any other string — including an OrderId. Branding breaks that on purpose: intersect the base type with a phantom property (& { __brand: "UserId" }) that nothing ever sets at runtime. Now UserId and OrderId have different types, so the compiler refuses to mix them.
// A brand is a phantom property that exists only in the type system:
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
// Smart constructors are the ONLY way to mint a branded value:
const makeUserId = (s: string) => s as UserId;
const makeOrderId = (s: string) => s as OrderId;
function getUser(id: UserId) { /* ... */ }
const u = makeUserId("u_1");
const o = makeOrderId("o_1");
getUser(u); // ✅
// getUser(o); // ❌ OrderId is not assignable to UserId (brands differ)
// getUser("u_1"); // ❌ a plain string isn't a UserId either// BRANDED TYPES fake "nominal typing" — making two same-shaped types DISTINCT.
// Normally TS is structural, so UserId and OrderId (both strings) are mixable.
// In TS we add a phantom 'brand':
// type UserId = string & { readonly __brand: "UserId" };
// type OrderId = string & { readonly __brand: "OrderId" };
// The brand exists ONLY in the type — at runtime the value is just a string.
// Smart constructors attach the brand (a no-op at runtime):
function makeUserId(s) { return s; } // in TS: returns UserId
function makeOrderId(s) { return s; } // in TS: returns OrderId
const u = makeUserId("u_123");
const o = makeOrderId("o_999");
// At runtime they're plain strings — the brand is gone:
console.log(typeof u); // string
console.log(u); // u_123
console.log(o); // o_999
// In TS, passing an OrderId where a UserId is required is now an ERROR,
// even though both are strings — the brands differ. That's the whole point.2. Why Brand: Ids and Units
The motivation is preventing values that share a type but not a meaning from mixing. User ids vs order ids is one case; physical units are another famous one — meters and feet are both numbers, and silently adding them has caused real, costly failures. Brand them and the compiler forbids the mix, requiring an explicit conversion function to move between them.
// Units are a classic source of catastrophic bugs (lost spacecraft, even!).
// Brands make different units incompatible at compile time:
type Meters = number & { readonly __unit: "m" };
type Feet = number & { readonly __unit: "ft" };
const m = (n: number) => n as Meters;
const ft = (n: number) => n as Feet;
function climb(height: Meters) { /* ... */ }
climb(m(100)); // ✅
// climb(ft(100)); // ❌ Feet is not assignable to Meters
// climb(100); // ❌ a raw number isn't Meters either
// Conversion must be explicit — you write feetToMeters and the types enforce it.// WHY brand? To stop mixing values that share a type but NOT a meaning:
// user ids vs order ids, or meters vs feet. In TS these are different brands:
// type Meters = number & { __brand: "Meters" };
// type Feet = number & { __brand: "Feet" };
// You then CANNOT add Meters + Feet by accident.
// Runtime: the values are plain numbers; the safety was at compile time.
function meters(n) { return n; } // -> Meters
function feet(n) { return n; } // -> Feet
const distance = meters(100);
const height = feet(20);
// A function that ONLY accepts Meters won't take Feet in TS:
function describeMeters(m) { return m + " meters"; }
console.log(describeMeters(distance)); // 100 meters
// Converting requires an EXPLICIT function — no silent mixing:
function feetToMeters(f) { return f * 0.3048; } // -> Meters
console.log(describeMeters(feetToMeters(height)).slice(0, 6)); // 6.0963. Smart Constructors: Validation as Proof
Branding gets even more powerful with a smart constructor: a single function that validates raw input and only then brands it. Because that constructor is the only way to produce the branded type, any value of that type is a proof that validation already passed. Downstream code can trust an Email contains an @ without re-checking — eliminating repetitive guards and "I forgot to validate" bugs.
type Email = string & { readonly __brand: "Email" };
// The smart constructor validates, THEN brands — the only path to an Email:
function makeEmail(s: string): Email {
if (!s.includes("@")) throw new Error("invalid email");
return s as Email; // brand applied only after the check passes
}
// Downstream code can TRUST an Email without re-validating:
function sendTo(to: Email) { /* guaranteed to contain "@" */ }
const e = makeEmail("ada@site.com"); // ✅
// sendTo("oops"); // ❌ plain string isn't Email
// You can ONLY get an Email by going through makeEmail — that's the guarantee.// SMART CONSTRUCTORS validate before branding, so a branded value is a PROOF
// that the data passed a check. In TS the constructor returns Email | never:
// function makeEmail(s: string): Email {
// if (!s.includes("@")) throw new Error("invalid email");
// return s as Email; // brand applied only AFTER validation
// }
// At runtime it's a normal validate-then-return function.
function makeEmail(s) {
if (!s.includes("@")) throw new Error("invalid email: " + s);
return s; // in TS: 'as Email' — branded
}
const email = makeEmail("ada@site.com");
console.log(email); // ada@site.com
// Because only makeEmail can produce an Email, any value typed Email is
// GUARANTEED to have passed validation — you never re-check downstream.
try {
makeEmail("not-an-email");
} catch (e) {
console.log("Rejected: " + e.message); // Rejected: invalid email: not-an-email
}
// The brand turns "a validated email" into something the TYPE system tracks.🎯 Your Turn
A Positive brand guarantees a number is greater than zero. Fill in the blank marked ___ to halve it, then run it.
// 🎯 YOUR TURN — replace the ___ then press "Try it Yourself".
// In TS: type Positive = number & { __brand: "Positive" };
// 'makePositive' validates and brands; 'half' safely divides a Positive.
function makePositive(n) {
if (n <= 0) throw new Error("must be positive");
return n; // in TS: 'as Positive'
}
function half(p) {
return p / ___; // 👉 the divisor: 2
}
console.log(half(makePositive(10))); // 5
console.log(half(makePositive(7))); // 3.5
// ✅ Expected output:
// 5
// 3.5Common Errors (and the fix)
- ❌ "Type 'string' is not assignable to type 'UserId'" — you passed a raw string. ✅ Mint it through the smart constructor (makeUserId(s)) instead of using the bare value.
- ❌ "Type 'OrderId' is not assignable to type 'UserId'" — you crossed brands. ✅ That's the brand working; use the correct id, or convert deliberately.
- ❌ Branding with as everywhere — scattering assertions defeats the guarantee. ✅ Centralize branding in one validated constructor per type.
- ❌ Expecting the brand at runtime — there's no __brand on the value. ✅ Remember it's type-only; do runtime checks inside the constructor.
Pro Tips
- 💡 Define a reusable Brand<T, B> helper — type Brand<T, B> = T & { readonly __brand: B } — so every brand is a one-liner.
- 💡 Brand ids, units, and validated strings — emails, URLs, non-empty strings, positive integers — anywhere a wrong-but-same-typed value would bite.
- 💡 Funnel creation through one constructor per brand. The guarantee only holds if branding can't be done casually elsewhere.
- 💡 Brands are free. Zero runtime cost and no extra fields — the safety is entirely at compile time.
📚 Keep learning
- TypeScript Handbook — Type Aliases (intersections)
- TypeScript Handbook — Intersection Types
- TypeScript Handbook — Type Assertions (used to brand)
Mini-Challenge: Validated UserId
No blanks this time — just a brief and a starting outline. Build the constructor and lookup yourself, run it, and check your output against the example in the comments.
// 🎯 MINI-CHALLENGE: a validated, branded UserId with a lookup.
// In TS: type UserId = string & { __brand: "UserId" };
//
// Write makeUserId(s) that:
// 1. Throws "bad user id: <s>" if s does NOT start with "u_".
// 2. Otherwise returns s (branded as UserId in TS).
// Then write lookup(id) that returns db[id] or "unknown".
//
// Test: a valid id ("u_1"), an invalid id ("x_1", should throw),
// and a valid-but-missing id ("u_9", should be "unknown").
//
// ✅ Expected output:
// Ada
// bad user id: x_1
// unknown
const db = { u_1: "Ada", u_2: "Bo" };
// your code here🎉 Lesson Complete
- ✅ A branded type intersects a base type with a phantom brand
- ✅ It simulates nominal typing, so same-shaped types stop mixing
- ✅ Brand ids and units to prevent meaning-mismatch bugs
- ✅ Smart constructors validate then brand — the only way to mint the type
- ✅ A branded value is proof validation passed; brands are erased at runtime
- ✅ Next: Checkpoint — Type-Level Patterns, putting it all together
Practice quiz
What does a branded type let you simulate on top of TypeScript's structural system?
- Dynamic typing
- Runtime reflection
- Nominal typing - compatibility by name, not just shape
- Duck typing
Answer: Nominal typing - compatibility by name, not just shape. Branding makes two structurally-identical types distinct, simulating nominal typing (compatibility by name) atop structural typing.
How is a branded type like UserId typically written?
- string & { readonly __brand: "UserId" }
- string | { __brand: "UserId" }
- type UserId = new string()
- Brand<UserId>
Answer: string & { readonly __brand: "UserId" }. You intersect the base type with a phantom brand property: string & { readonly __brand: "UserId" }.
Does the brand property exist at runtime?
- Yes, as a real property on the value
- Only in strict mode
- Only on objects
- No - it is type-only and erased during compilation
Answer: No - it is type-only and erased during compilation. The brand is a phantom that lives only in the type system; at runtime a branded UserId is just an ordinary string.
Why is branding described as essentially free?
- It needs no npm package
- It has zero runtime cost and adds no extra fields
- It compiles faster than normal types
- It removes the need for tests
Answer: It has zero runtime cost and adds no extra fields. Brands are erased at compile time, so there's zero runtime cost and no extra property - all protection is at compile time.
If UserId and OrderId are both branded strings, what happens when you pass an OrderId where a UserId is required?
- It is a compile-time error because the brands differ
- It works because both are strings
- It silently converts at runtime
- It throws at runtime
Answer: It is a compile-time error because the brands differ. Different brands make the types incompatible, so passing an OrderId for a UserId is a compile error - exactly the point.
Can a plain, unbranded string be assigned to a branded UserId?
- Yes, always
- Only if it is non-empty
- No - it must pass through a constructor first
- Only inside an async function
Answer: No - it must pass through a constructor first. A raw string isn't assignable to a branded type; that's deliberate, forcing every value through a smart constructor.
What is a smart constructor in the context of branded types?
- A class constructor with default parameters
- The single function that validates raw input, then brands it
- A generic factory for any type
- A constructor that runs at compile time
Answer: The single function that validates raw input, then brands it. A smart constructor validates then brands - and because it's the only way to mint the type, the value proves validation passed.
Why can downstream code trust a value typed Email without re-validating?
- Email types are always non-empty
- TypeScript revalidates automatically
- Email values are immutable
- The only way to get an Email is through the validating constructor
Answer: The only way to get an Email is through the validating constructor. Since the branded Email can only come from makeEmail (which validates), any Email is proof validation already passed.
Why does TypeScript's structural typing allow ids and units to be mixed by default?
- Because it checks types by name
- Because two values with the same shape (e.g. both strings) are considered the same type
- Because it ignores all type errors
- Because numbers and strings are interchangeable
Answer: Because two values with the same shape (e.g. both strings) are considered the same type. Structural typing compares shape, so a UserId that's 'just a string' is interchangeable with any other string until you brand it.
Which reusable helper makes defining brands a one-liner?
- type Brand = any
- interface Brand extends string {}
- type Brand<T, B> = T & { readonly __brand: B }
- type Brand<T> = Partial<T>
Answer: type Brand<T, B> = T & { readonly __brand: B }. A generic helper type Brand<T, B> = T & { readonly __brand: B } lets every brand be defined in a single line.
Continue this course
- Previous: The satisfies Operator
- Next: Checkpoint: Type-Level Patterns — Combine satisfies, branded and template-literal types — then a quiz
- Quick reference: TypeScript cheat sheet
Frequently asked questions
What is a branded (nominal) type?
A branded type is a trick that makes TypeScript treat two structurally-identical types as distinct. You intersect a base type with a phantom 'brand' property — type UserId = string & { __brand: 'UserId' } — that exists only in the type system. Because UserId and a plain string (or an OrderId with a different brand) no longer have the same type, the compiler refuses to mix them. It simulates nominal typing (compatibility by name) on top of TypeScript's normally structural system.
Does the brand exist at runtime?
No. The brand is a phantom — it's part of the type only and is erased during compilation. At runtime a branded UserId is just an ordinary string, and a branded Meters is just a number. There's zero runtime cost and no extra property on the value. All the protection happens at compile time, which is exactly why branding is essentially free.
Why would I want to prevent mixing same-typed values?
Because sharing a base type doesn't mean sharing a meaning. A user id and an order id are both strings, but passing one where the other is expected is a serious bug the compiler can't normally catch. The same goes for units: meters and feet are both numbers, and silently adding them has caused real, expensive failures. Branding lets the type system enforce that these values stay in their own lane and are only converted through explicit functions.
What is a smart constructor?
A smart constructor is the single, controlled function that validates raw data and then brands it — for example makeEmail(s) that throws unless s contains '@', then returns s as Email. Because branded values can only be produced by going through the constructor, any value of the branded type is a proof that validation already passed. Downstream code can trust an Email without re-checking it, which removes repetitive validation and a whole class of 'I forgot to check' bugs.