TypeScript Branded Types: Faking Nominal Typing with __brand to Stop Primitive Bugs
A function takes two strings. You pass them in the wrong order. TypeScript says nothing, your tests pass, and the bug reaches production, where a customer's invoice is attached to someone else's account.
This is one of the most common bugs in typed codebases, and the compiler can't catch it because of a deliberate design choice: TypeScript is structurally typed. A string is a string, whether it holds a user ID, an order ID, an email address, or a poem.
This guide explains why that happens, then shows how to fix it with branded types (also called opaque or tagged types). A brand fakes nominal typing with a phantom __brand property, so the compiler treats UserId and OrderId as different types even though both are strings at runtime. It covers the basic pattern, the unique symbol variant, validation at boundaries, numeric units, subtype brands, Zod integration, testing, and the pitfalls that catch teams out.
Key takeaways
TypeScript compares types by shape, not by name. Two aliases of string are fully interchangeable.
A brand is an intersection such as string & { readonly __brand: "UserId" }. It exists only at compile time and adds no runtime cost.
Brands are only as trustworthy as the places where you create them. Put validation in smart constructors at system boundaries.
Prefer a unique symbol key over a plain __brand string key in shared libraries to avoid property collisions.
Use Map<UserId, User> rather than Record<UserId, User>, and be careful: arithmetic and string methods return unbranded values.
Protect your brands with @ts-expect-error type tests and a lint rule that restricts as casts.
Structural typing vs. nominal typing
A type system decides when one type can be used where another is expected. There are two main approaches.
Nominal typing compares names. Two types are compatible only if one is explicitly declared as related to the other. Java, C#, and Rust (for structs) work this way. In C#, a Person class that happens to have a Name property is not a Named interface unless it declares : Named.
Structural typing compares shape. If a value has the members the target type requires, it's compatible, regardless of what either type is called. The TypeScript Handbook describes this directly: type compatibility is based on structural subtyping, which relates types based solely on their members, in contrast with nominal typing. The handbook explains that the design follows how JavaScript is typically written, with anonymous objects and object literals everywhere.
Structural typing is a good fit for JavaScript. It lets you pass any object with the right fields to a function without ceremony, which keeps the type system out of the way. The cost is that identical shapes are indistinguishable:
class Dollars {
constructor(public amount: number) {}
}
class Euros {
constructor(public amount: number) {}
}
const price: Dollars = new Euros(10); // compiles: same shape, so compatible
Here two different classes, with two different meanings and currencies, are freely interchangeable. For objects with distinctive shapes this rarely matters. For primitives it matters a great deal, because primitives have no shape beyond "string" or "number".
The bug: primitive obsession
Developers call this habit primitive obsession: representing domain concepts (IDs, emails, money, distances) with raw string and number. It's convenient, and it's where the bugs come from.
type UserId = string;
type OrderId = string;
function cancelOrder(userId: UserId, orderId: OrderId): void {
// ...removes the order from the user's account
}
const userId: UserId = "usr_8f3a21";
const orderId: OrderId = "ord_19c2d7";
cancelOrder(orderId, userId); // compiles, and is wrong
The aliases are only labels. UserId and OrderId both resolve to string, so the compiler sees cancelOrder(string, string) and accepts any pair. The type aliases document your intent, but nothing enforces it.
Other places this bites:
Functions with several same-typed parameters: transfer(from, to, amount), resize(width, height).
Mixed units: meters vs. feet, cents vs. dollars, milliseconds vs. seconds.
Unvalidated data used where validated data is required: a raw string passed to a function that expects a sanitized or verified email.
Mixed identifier spaces in data layers: looking up a product with a customer ID.
None of these produce a compile error, and many produce no runtime error either. They quietly return the wrong record, charge the wrong amount, or fail only for certain inputs.
The idea behind branding
If the compiler only compares shapes, the fix is to give each domain type a distinct shape. A brand adds a property that exists only in the type system:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
UserId is string & { readonly __brand: "UserId" }. It's an intersection type (the handbook's term for combining types so a value must satisfy all of them), so a UserId still behaves like a string and also carries a brand property whose type is the literal "UserId". OrderId has the same shape except its brand is "OrderId". The two brand types are incompatible.
declare function cancelOrder(userId: UserId, orderId: OrderId): void;
declare const userId: UserId;
declare const orderId: OrderId;
cancelOrder(userId, orderId); // OK
cancelOrder(orderId, userId); // Error: OrderId is not assignable to UserId
const raw = "usr_8f3a21";
cancelOrder(raw, orderId); // Error: string is not assignable to UserId
The swapped call now fails at compile time, and so does passing an unvalidated raw string.
The brand is a lie that happens to be useful. At runtime a UserId is just a string. There is no __brand property on it, and no code ever creates one. TypeScript is told the property exists, and the type system enforces what follows from that. That's why this is called a phantom property. It has zero runtime cost: no wrapper object, no allocation, no extra bytes in your JSON.
Creating branded values: smart constructors
If branding is just a type-level label, how do you get a value of a branded type? You need to create it, and that creation point is the most important design decision in the pattern.
The simplest way is a type assertion:
const id = "usr_8f3a21" as UserId;
Casting works because UserId is a subtype of string, so the assertion is allowed. But sprinkling as UserId through a codebase defeats the purpose, since anyone can brand anything. The better approach is a smart constructor: one function per brand that validates the input and is the only place the assertion happens.
const USER_ID_PATTERN = /^usr_[a-z0-9]+$/;
export function toUserId(value: string): UserId {
if (!USER_ID_PATTERN.test(value)) {
throw new TypeError(`Invalid user id: ${value}`);
}
return value as UserId;
}
If throwing doesn't suit your error-handling style, return a result type instead:
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
type Email = Brand<string, "Email">;
export function parseEmail(input: string): Result<Email> {
const trimmed = input.trim().toLowerCase();
// Deliberately simple: real validation belongs in a vetted library.
const looksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
return looksValid
? { ok: true, value: trimmed as Email }
: { ok: false, error: "Not a valid email address" };
}
This is the idea often summarized as "parse, don't validate." A plain boolean check throws away what it learned. A parse function returns a new type that records the check passed. Any function that accepts Email can trust that validation already happened, without repeating it.
Type guards and assertion functions
Smart constructors aren't the only way in. TypeScript's narrowing features, including type predicates and assertion functions (covered in the Handbook's narrowing chapter), can brand values in place:
export function isUserId(value: string): value is UserId {
return USER_ID_PATTERN.test(value);
}
export function assertUserId(value: string): asserts value is UserId {
if (!USER_ID_PATTERN.test(value)) {
throw new TypeError(`Invalid user id: ${value}`);
}
}
function handle(input: string) {
if (isUserId(input)) {
input; // narrowed to UserId inside this block
}
}
Use is when you want to branch on validity and asserts when invalid input should stop execution.
The unique symbol variant
The __brand string key is easy to read, but it has two weaknesses. It can collide with a real property named __brand (unlikely but possible in generated code), and it shows up as a normal-looking member in autocomplete.
A common refinement keys the brand with a unique symbol. This TypeScript feature has existed since version 2.7. Each unique symbol declaration is a distinct type, so nothing else can collide with it:
declare const brand: unique symbol;
export type Brand<T, B extends string> = T & {
readonly [brand]: B;
};
declare const means nothing is emitted at runtime. The symbol exists only for the type checker. Everything else works as before:
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
For application code, either form is fine. For a shared package consumed by many teams, prefer the symbol form.
Branded numbers: units and money
Brands are as useful for numbers as for strings, and unit mix-ups are a classic source of costly bugs.
type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;
export const cents = (n: number): Cents => {
if (!Number.isInteger(n)) {
throw new RangeError(`Cents must be an integer, received ${n}`);
}
return n as Cents;
};
export const addCents = (a: Cents, b: Cents): Cents => (a + b) as Cents;
export const toDollars = (c: Cents): Dollars => (c / 100) as Dollars;
declare function chargeCard(amount: Cents): void;
const price = cents(1999);
chargeCard(price); // OK
chargeCard(toDollars(price)); // Error: Dollars is not assignable to Cents
chargeCard(19.99); // Error: number is not assignable to Cents
Note the explicit casts inside addCents and toDollars. Arithmetic on a branded number returns a plain number, because the + operator knows nothing about your domain. This is a feature: each arithmetic operation that should preserve the brand lives in one small, auditable function, and the rest of the code can't accidentally produce "cents" from nothing.
The same applies to branded strings. userId.toUpperCase() returns string, not UserId. A brand marks a value that has been validated, and transforming it invalidates that claim until you re-validate.
type Id<Entity extends string> = Brand<string, `${Entity}Id`>;
type UserId = Id<"User">; // brand: "UserId"
type OrderId = Id<"Order">; // brand: "OrderId"
type ProductId = Id<"Product">; // brand: "ProductId"
Each entity gets its own incompatible ID type from a single line. You can pair this with a generic constructor if your ID formats share validation rules:
export function makeId<E extends string>(
entity: E,
prefix: string,
value: string,
): Id<E> {
if (!value.startsWith(`${prefix}_`)) {
throw new TypeError(`Expected ${entity} id to start with "${prefix}_"`);
}
return value as Id<E>;
}
const userId = makeId("User", "usr", "usr_8f3a21"); // Id<"User">
Subtype brands: branding a brand
Sometimes one validated type should be a refinement of another. A VerifiedEmail is an Email, so you want to pass it anywhere an Email is accepted, but not the reverse.
The naive attempt fails in a subtle way:
type Email = Brand<string, "Email">;
type VerifiedEmail = Brand<Email, "VerifiedEmail">;
// The brand property would be "Email" & "VerifiedEmail", which is impossible.
// TypeScript reduces the intersection of conflicting literal property types to never.
When two intersected object types give the same property two different literal types, TypeScript reduces the whole intersection to never. The usual fix is to store the brand as a set of flags instead of a single literal:
declare const brand: unique symbol;
export type Brand<T, B extends string> = T & {
readonly [brand]: { readonly [K in B]: true };
};
type Email = Brand<string, "Email">;
type VerifiedEmail = Brand<Email, "VerifiedEmail">;
declare function sendNewsletter(to: Email): void;
declare function resetPassword(to: VerifiedEmail): void;
declare const plain: Email;
declare const verified: VerifiedEmail;
sendNewsletter(verified); // OK: a VerifiedEmail is an Email
resetPassword(plain); // Error: Email is not a VerifiedEmail
Now the brand property of VerifiedEmail is { Email: true } & { VerifiedEmail: true }, which is satisfiable, and the subtype relationship works the way you'd expect. Use this form if you need hierarchies, and the simpler form if you don't.
Branding at system boundaries with Zod
Branded types protect the inside of your application. The risk lives at the edges, where data enters from HTTP requests, databases, queues, local storage, and JSON.parse. All of those produce string, number, or any, and none of them know about your brands.
The rule is simple: brand once, at the boundary, and nowhere else. If you already validate inputs with a schema library, you can brand in the same step. Zod's .brand() method does exactly this: the schema's inferred output type becomes the branded type, with no runtime change to the value.
Check Zod's documentation for the exact brand type in your installed version, since details differ between major versions. The pattern is the same either way: one parse step at the edge turns untrusted input into trusted, branded values. For a fuller treatment of schema-validated boundaries, see our post on type-safe server-driven UI in React, which uses the same "validate at the edge, trust inside" approach.
A practical note on generated types
Tools that turn JSON into TypeScript, including our free JSON to TypeScript Converter, produce interfaces with plain string and number fields, because JSON carries no domain information. That's the right starting point. Treat the generated interface as the raw shape of the payload, then map it into your domain model, where fields like id: string become id: UserId. Likewise, when you need realistic test IDs, the Bulk UUID/GUID Generator can produce fixtures that you pass through your smart constructors.
Using branded types as keys and in collections
Branded strings work well in Map and Set, with inference doing the right thing:
const users = new Map<UserId, User>();
users.set(userId, user);
users.get(orderId); // Error: OrderId is not assignable to UserId
Prefer Map over Record<UserId, User> for branded keys. Object property keys are strings at runtime, and mapped types over tagged primitives can behave unexpectedly. Map keeps the key type intact, which also keeps the safety guarantees.
Branded values serialize normally, because they're just strings and numbers: JSON.stringify gives you the plain value. Deserialization is where you must re-brand, which brings you back to the boundary rule above.
Alternatives to __brand
Branding isn't the only way to get nominal behavior in TypeScript.
Classes with private members. The Handbook notes that private and protected members affect compatibility. When the target type has a private member, the source must have one that originated in the same declaration. That makes classes with private fields nominal:
class Email {
private readonly _brand = "Email";
constructor(readonly value: string) {}
}
class Username {
private readonly _brand = "Username";
constructor(readonly value: string) {}
}
declare function sendTo(email: Email): void;
sendTo(new Username("ada")); // Error: separate declarations of '_brand'
This gives real nominal typing, including instanceof checks, but it costs a runtime allocation per value and changes how the value is serialized and compared. It's a good fit for rich domain objects and a poor fit for high-volume primitives.
Empty enums. Enums are nominally typed, so some codebases brand with T & EmptyEnum. It works, but it ties a type-only concept to a construct that can emit runtime code, so most teams prefer a symbol or string brand.
Libraries. Zod provides .brand(), and general utility libraries such as type-fest offer a tagged-type helper. They're convenient, but the pattern is small enough to own yourself, and owning it means no extra dependency.
Technique
Runtime cost
Works on primitives
Subtype relationships
Best for
T & { __brand }
None
Yes
With flag-style brands
IDs, units, validated strings
unique symbol brand
None
Yes
With flag-style brands
Shared libraries
Class with private field
One object per value
No (wraps them)
Via inheritance
Rich domain objects
Empty enum intersection
None
Yes
Limited
Legacy code already using enums
Zod .brand()
None (type-only)
Yes
Per schema
Boundary validation
Testing your brands
A brand that silently stops working is worse than no brand, because the team still believes it's protected. Because brands are compile-time constructs, test them with the compiler. The @ts-expect-error directive, available since TypeScript 3.9, turns "this line must fail to compile" into an assertion: if the line stops producing an error, the directive itself becomes an error.
// brand.test-d.ts: compiled in CI with `tsc --noEmit`, never executed
declare const userId: UserId;
declare const orderId: OrderId;
// @ts-expect-error a plain string must not be accepted
cancelOrder("usr_1", orderId);
// @ts-expect-error arguments in the wrong order must not compile
cancelOrder(orderId, userId);
// @ts-expect-error Dollars must not be accepted where Cents are expected
chargeCard(toDollars(cents(100)));
// Positive case: this must keep compiling
cancelOrder(userId, orderId);
Run tsc --noEmit in your pipeline. If somebody loosens a type (for example by turning Brand<string, "UserId"> into a bare alias), the @ts-expect-error lines flip from "expected error" to "unused directive," and the build fails.
Guarding the escape hatch
The biggest weakness of branding is as. A type assertion tells the compiler to trust you, so "anything" as UserId always compiles. The TypeScript Handbook describes assertions as a way to specify a more specific type than the compiler can infer, and notes they're removed at compile time and have no runtime checks.
Two cheap defenses help:
Keep casts in one module. Put each brand's type, constructor, and guard in the same file. Review any as SomeBrand found elsewhere as a probable bug.
Lint for it. ESLint's no-restricted-syntax rule can flag assertions to branded types outside the files that own them:
// eslint.config.js (excerpt)
export default [
{
rules: {
"no-restricted-syntax": [
"error",
{
selector: "TSAsExpression > TSTypeReference > Identifier[name=/Id$/]",
message:
"Don't cast to branded ID types. Use the smart constructor instead.",
},
],
},
},
{
// The modules that own the brands may cast.
files: ["src/domain/ids/**"],
rules: { "no-restricted-syntax": "off" },
},
];
Adjust the selector to your naming convention.
Common pitfalls
Branding too much. Not every string needs a brand. Skip them for values that are never confused with each other, and for object types that already differ in shape. A good filter is to ask whether swapping two values of this type has ever caused, or could plausibly cause, a bug.
Expecting runtime protection. Branded types vanish at runtime. They don't prevent a malicious or malformed payload from reaching your code. Only validation does that, which is why smart constructors and boundary schemas matter more than the brand itself.
Forgetting that operations unbrand. String methods, template literals, and arithmetic all produce plain string and number. Wrap operations that must preserve a brand in small named functions.
Ambiguous error messages. Errors involving intersections can be harder to read than errors about simple aliases. Naming your brands clearly (UserId, not Brand1) and hovering over types in your editor makes the failures understandable.
Mixing brand styles. If one module uses __brand strings and another uses unique symbol keys, the two families of types never interoperate. Pick one Brand helper per codebase and export it from a single place.
Declaration output in libraries. When publishing a package, make sure the Brand helper and its declare const are part of the emitted .d.ts files, so consumers see the same brands you do.
A migration plan for an existing codebase
You don't need a big-bang rewrite. A gradual path works well:
Start with identifiers. IDs are the highest-value, lowest-effort target. Brand UserId, OrderId, and similar types first.
Create the constructors before touching call sites. Put each brand and its constructor in one module.
Brand at the boundaries. Update controllers, repository functions, and API clients so that raw strings are converted exactly once on the way in.
Let the compiler guide you. Changing a function's parameter from string to UserId produces a precise list of call sites that now need real values. Many of these errors will be genuine bugs you didn't know about.
Add type tests and the lint rule once the first few brands exist, so the pattern doesn't erode.
Extend to units and validated values such as Cents, Milliseconds, and Email when the team is comfortable.
When not to use branded types
Branding trades some convenience for safety, and that isn't always worth it. Avoid or postpone it when:
the codebase is a small script or prototype with a short lifespan;
values flow through very few functions and mix-ups are implausible;
your team isn't yet comfortable with intersection and generic types, since a half-understood pattern causes more friction than it prevents;
the value is already protected by a distinctive object shape or a union of literals.
Frequently asked questions
What are branded types in TypeScript?
Branded types are a pattern for making two types with the same runtime representation incompatible at compile time. You intersect a base type, such as string, with an object type that carries a phantom property, such as { readonly __brand: "UserId" }. The property never exists at runtime, but the compiler enforces it.
Does TypeScript support nominal typing natively?
No. TypeScript's type compatibility is structural. The closest built-in behavior is classes with private or protected members, which become compatible only with types originating from the same declaration. Branding is the common technique for getting nominal behavior on primitives.
Do branded types have a runtime cost?
No. The brand exists only in the type system, and nothing is emitted for it. The only runtime code is the validation you choose to put in your smart constructors.
Is it safe to cast with as to create a branded value?
It's safe only if the cast happens after real validation, in one controlled place. Casting arbitrary values at arbitrary call sites defeats the pattern, which is why smart constructors, boundary schemas, and a lint rule are recommended.
What's the difference between a type alias and a branded type?
A type alias such as type UserId = string is only another name for string, so it's fully interchangeable with every other string. A branded type creates a genuinely distinct type that the compiler won't let you mix up with other strings or other brands.
Should I use __brand or a unique symbol?
Both work. A unique symbol key is safer for shared libraries because nothing can collide with it. A string __brand key is simpler to read and is fine for application code.
Conclusion
TypeScript's structural typing is a strength for JavaScript interoperability and a weakness for domain modeling. When UserId, OrderId, Cents, and Dollars are all just string or number to the compiler, the type system can't help you with the bugs that matter most.
Branded types close that gap with almost no ceremony: one Brand helper, a smart constructor per type, validation at the boundaries, and a few type-level tests to keep it honest. Start with your IDs, let the compiler show you where the swapped arguments were hiding, and expand from there. For most teams, the pattern pays for itself the first time it flags a mix-up that would otherwise have shipped.