TypeScript Branded Types Tutorial
Use branded types in TypeScript to add nominal safety on top of structural types. Stop mixing up UserIds, emails, and raw strings at compile time.
What you'll learn
- ✓What branded types are and why they exist
- ✓How to add nominal safety to structural types
- ✓Building constructors and smart parsers
- ✓Patterns for ids, emails, and validated values
- ✓When brands hurt more than they help
Prerequisites
- •Basic TypeScript
- •Familiarity with intersection types
What and Why
TypeScript uses structural typing: two types are compatible if their shapes match. That is usually a feature, but it bites when two strings represent very different things. A UserId and a PostId are both string, so the compiler happily lets you pass one where the other is expected. The same is true for a raw user input string and a validated email.
Branded types add a synthetic marker to a value’s type so the compiler treats otherwise identical shapes as distinct. At runtime, the value is still a plain string or number. At compile time, only constructors you control can produce the branded form, giving you the safety of nominal typing without giving up structural ergonomics elsewhere.
Mental Model
Think of a brand as a sticker. The underlying value is unchanged, but the sticker tells the type system: “this string has been blessed as a UserId.” The sticker is invisible at runtime, but it cannot be peeled off accidentally because TypeScript checks it on every assignment.
The trick is to intersect the base type with an object that carries an unforgeable tag. Because the tag uses a unique symbol or a property no one would type by hand, the only way to get a branded value is through a constructor that returns the branded type.
Hands-on Example
The minimal brand looks like this.
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
function asUserId(s: string): UserId {
return s as UserId;
}
const u: UserId = asUserId('u_123');
const p: PostId = u; // Error: brand mismatch
The unique symbol ensures no one can construct that property literally. The constructor is a single typed cast, isolated in one place. Everywhere else, you receive and pass UserId values that cannot be confused with PostId or with a bare string.
A more interesting case is validation. A ValidEmail brand guarantees that any value of that type has actually passed the email check.
type ValidEmail = Brand<string, 'ValidEmail'>;
function parseEmail(input: string): ValidEmail | null {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input)
? (input as ValidEmail)
: null;
}
function send(to: ValidEmail) { /* ... */ }
const e = parseEmail(userInput);
if (e) send(e); // safe, narrowed to ValidEmail
The brand makes “validated” a compile-time property. Any function that takes a ValidEmail knows the input is real, so the check happens once at the boundary instead of being scattered defensively.
string (raw, unchecked)
|
+----> asUserId() ------> UserId
|
+----> asPostId() ------> PostId
|
+----> parseEmail() ----> ValidEmail | null
Compiler treats UserId, PostId, ValidEmail as
distinct types even though they all carry strings. You can compose brands. A NonEmpty<string> and a Trimmed<string> can be intersected to mean both. Order of branding does not matter as long as the constructor enforces every invariant before returning.
Common Pitfalls
The biggest mistake is leaking the cast. If you scatter as UserId calls through your codebase, the brand becomes decorative. Keep constructors in one module and forbid casts elsewhere with an ESLint rule.
Another trap is treating brands as runtime markers. They are not. The property does not exist on the value. Do not serialize it, do not log it, do not check for it with in. The brand lives only in the type checker.
A subtle issue is JSON deserialization. When you parse JSON, you receive plain strings, not branded ones. Use a parser like Zod or a hand-written validator at the boundary to brand values as they enter the system.
Brands can also interact awkwardly with libraries that expect bare string. Most accept the branded type because of structural compatibility, but some generic helpers infer the wrong type. When that happens, narrow with as string at the call site, not earlier.
Best Practices
Put each brand and its constructor in one small module. Export the type and the constructor; never export a way to bypass the constructor. This single point of trust is what makes brands safe.
Name brands for invariants, not implementations. NonEmptyString and ValidEmail describe guarantees. UserId and PostId describe domains. Both are useful; mixed metaphors are not.
Pair brands with parsers that return Result or nullable types. This forces callers to handle invalid inputs explicitly instead of throwing deep in business logic.
Document brands in code comments or README. Newcomers see UserId and wonder why they cannot just pass a string. A one-line note pointing to the constructor saves hours.
Wrap-up
Branded types give you nominal safety inside a structural type system. They turn dangerous primitives like ids and emails into self-documenting, hard-to-misuse values without runtime cost. The pattern is small, the payoff is large, and once a codebase adopts brands at its boundaries, whole classes of bugs simply stop happening.
Add brands where shape collisions cost the most: ids, validated inputs, currency, and units. Skip them where the friction outweighs the safety. Used judiciously, brands are one of TypeScript’s best-kept secrets.
Related articles
- TypeScript TypeScript Enums vs Union Literals
Compare TypeScript enums with string union literals. Learn the trade-offs in runtime cost, exhaustiveness, ergonomics, and when each is the right choice.
- TypeScript TypeScript Type Guards Deep Dive
Master TypeScript type guards: typeof, instanceof, discriminated unions, user-defined predicates, and assertion functions. Narrow types safely and idiomatically.
- TypeScript TypeScript Branded Types for Nominal Typing
Add nominal typing to TypeScript using branded types so you cannot accidentally mix UserId, OrderId, raw strings, or money values.
- TypeScript TypeScript Conditional Types Tutorial
Learn conditional types in TypeScript: distribution, infer, and how to build expressive utility types that adapt to the input shape.