cheat sheet
Branded Types
Branded (a.k.a. nominal, opaque, tagged) types add a phantom marker to a primitive so the compiler refuses to mix two strings — useful for IDs, units, validated input, and money. Covers hand-rolled brands, type-fest's Opaque/Tagged, Zod's .brand(), class-based brands, and runtime validation pairings.
Branded Types — Nominal Typing in a Structural System
What it is
TypeScript is structurally typed: two strings are interchangeable, and any object with the right shape is assignable to any matching interface. That is great for ergonomic interop but disastrous when you have a UserId and a PostId — both are string at runtime, so the compiler will happily let you pass one in place of the other. A branded type (also called nominal, opaque, or tagged) adds a phantom marker to a primitive so the compiler refuses to mix them, while the runtime representation stays the same plain string/number. The pattern is the canonical solution for IDs, validated input (Email, Url), units (Meters, Celsius), and money amounts (USD, EUR).
Install
Branded types are a pure pattern — no install is needed for the hand-rolled version. The most common helpers ship from type-fest, and Zod has built-in .brand() support.
# Hand-rolled brands — nothing to install
# Optional: type-fest provides Opaque, Tagged, UnwrapTagged
npm install type-fest
# Optional: Zod for runtime-validated brands
npm install zod
Output: (none — exits 0 on success)
Syntax
A branded type is an intersection of the base type with a "phantom" property — a property that exists only in the type system. The convention is to use a __brand field (or a unique symbol) so the brand cannot be accidentally constructed.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
Output: (none — exits 0 on success)
Essential patterns
| Pattern | Where to use it |
|---|---|
Hand-rolled { __brand: "X" } | One-off brands, no extra dependency |
unique symbol brand | Maximum safety — symbols are unforgeable |
type-fest Opaque<T, B> | Standard library helper, well-known idiom |
type-fest Tagged<T, B> (4.x) | Newer name for Opaque — preferred for new code |
Zod .brand<"X">() | Brand plus runtime validation |
Class with private #brand | When you also need methods/encapsulation |
The structural-typing problem
Without brands, two semantically-different strings are interchangeable — the compiler has no way to tell them apart. This is the bug class branded types eliminate.
function deletePost(userId: string, postId: string): void {
console.log(`user ${userId} deleted post ${postId}`);
}
const userId = "u_42";
const postId = "p_99";
// Accidentally swapped — no error
deletePost(postId, userId);
Output:
user p_99 deleted post u_42
That output is wrong but compiles fine. The fix is to give each ID a distinct type so the compiler catches the swap at the call site.
Hand-rolled branded types
The minimum-viable brand is an intersection with a __brand literal. Use a factory function to construct values — the cast is centralised, so the rest of the codebase never sees an as assertion.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
function userId(s: string): UserId { return s as UserId; }
function postId(s: string): PostId { return s as PostId; }
function deletePost(u: UserId, p: PostId): void {
console.log(`user ${u} deleted post ${p}`);
}
const u = userId("u_42");
const p = postId("p_99");
deletePost(u, p); // OK
// deletePost(p, u); // Error — PostId not assignable to UserId
// deletePost("u_42", p); // Error — string not assignable to UserId
Output:
user u_42 deleted post p_99
The runtime representation is identical to a plain string — typeof u === "string", JSON.stringify(u) is "u_42". The brand exists only at type-check time, so there is zero runtime cost.
unique symbol brands
A more forgery-resistant variant uses a unique symbol as the brand key. Since unique symbol types cannot be constructed outside their declaration site, an outside module cannot fabricate a value of the branded type even with as — the symbol property would still be missing.
declare const userIdBrand: unique symbol;
declare const postIdBrand: unique symbol;
type UserId2 = string & { readonly [userIdBrand]: never };
type PostId2 = string & { readonly [postIdBrand]: never };
function userId2(s: string): UserId2 { return s as UserId2; }
function postId2(s: string): PostId2 { return s as PostId2; }
const u2 = userId2("u_42");
const p2 = postId2("p_99");
console.log(`${u2} ${p2}`);
Output:
u_42 p_99
The trade-off: error messages mention the symbol's printed form, which is uglier than a friendly "UserId" string literal. For most application code the string-brand variant is more pleasant; reach for unique-symbol brands in libraries published to npm where stronger isolation matters.
Opaque and Tagged from type-fest
type-fest ships two functionally identical helpers — Opaque (older) and Tagged (newer) — that wrap the brand pattern in a well-known utility. Use them to avoid copy-pasting the Brand<T, B> definition into every project.
import type { Opaque, Tagged, UnwrapTagged } from "type-fest";
type AccountId = Opaque<string, "AccountId">;
type Email = Tagged<string, "Email">;
function accountId(s: string): AccountId { return s as AccountId; }
function email(s: string): Email {
if (!s.includes("@")) throw new Error(`invalid email: ${s}`);
return s as Email;
}
const id: AccountId = accountId("acct_1");
const addr: Email = email("alice@example.com");
type RawEmail = UnwrapTagged<Email>; // string
console.log(id, addr);
Output:
acct_1 alice@example.com
UnwrapTagged<T> is the inverse operation — useful when you need to interop with an API that takes a plain string. It strips the brand off without as.
Validated construction with Zod
The brand pattern composes naturally with Zod's .brand() — the schema both validates the input and produces a branded type, so an Email value is guaranteed to contain an @ (and any other rule the schema enforces). This is the gold standard for production code.
import { z } from "zod";
const EmailSchema = z.string().email().brand<"Email">();
const UserIdSchema = z.string().uuid().brand<"UserId">();
type Email = z.infer<typeof EmailSchema>;
type UserId = z.infer<typeof UserIdSchema>;
const e = EmailSchema.parse("alice@example.com");
const u = UserIdSchema.parse("550e8400-e29b-41d4-a716-446655440000");
function sendInvite(to: Email, from: UserId): void {
console.log(`invite ${to} from ${from}`);
}
sendInvite(e, u); // OK
// sendInvite(u, e); // Error — branded mismatch
// const bad = EmailSchema.parse("not-an-email"); // Throws ZodError
Output:
invite alice@example.com from 550e8400-e29b-41d4-a716-446655440000
The schema acts as both runtime validator and brand-issuing factory — no separate email(s: string): Email constructor is required, and the brand is impossible to fake because the only way to obtain one is through .parse() or .safeParse().
Units of measure
Branded types let you encode physical or logical units so the compiler catches meters + feet (a real NASA-level bug) and unsanitized + sql (the entire class of injection vulnerabilities).
type Meters = number & { readonly __unit: "m" };
type Feet = number & { readonly __unit: "ft" };
type Celsius = number & { readonly __unit: "C" };
type Fahrenheit = number & { readonly __unit: "F" };
const m = (n: number) => n as Meters;
const ft = (n: number) => n as Feet;
const c = (n: number) => n as Celsius;
function metersToFeet(x: Meters): Feet { return ft(x * 3.28084); }
const distance: Meters = m(100);
const inFeet = metersToFeet(distance);
// const wrong = metersToFeet(ft(100)); // Error
console.log(`${distance}m = ${inFeet.toFixed(1)}ft`);
// const bad: Meters = c(20); // Error — Celsius is not a Meters
Output:
100m = 328.1ft
Money
Money is one of the highest-payoff applications of branding — currencies must never mix without explicit conversion, and the bug of "adding USD to EUR" is invisible without nominal types.
type Money<C extends string> = number & { readonly __currency: C };
type USD = Money<"USD">;
type EUR = Money<"EUR">;
type JPY = Money<"JPY">;
const usd = (n: number) => n as USD;
const eur = (n: number) => n as EUR;
const jpy = (n: number) => n as JPY;
function addUsd(a: USD, b: USD): USD { return (a + b) as USD; }
const balance = usd(100);
const fee = usd(2.5);
const total = addUsd(balance, fee);
console.log(`total: $${total}`);
// const bad = addUsd(usd(100), eur(50)); // Error
// const wrong: USD = 100; // Error — plain number is not USD
Output:
total: $102.5
Validated input
Brands are perfect for "this string has been validated" markers — Sanitized, Trimmed, LowerCased, SqlSafe. The validator is the only code path that constructs the branded value, so once you have one you can trust its invariants without re-checking.
type NonEmpty = string & { readonly __brand: "NonEmpty" };
type Trimmed = string & { readonly __brand: "Trimmed" };
type Sanitized = string & { readonly __brand: "Sanitized" };
function nonEmpty(s: string): NonEmpty {
if (s.length === 0) throw new Error("empty string");
return s as NonEmpty;
}
function trimmed(s: string): Trimmed { return s.trim() as Trimmed; }
function sanitize(s: string): Sanitized {
return s.replace(/[<>'"&]/g, "") as Sanitized;
}
function renderName(s: Sanitized): string {
return `<h1>${s}</h1>`; // safe — already sanitized
}
const raw = " Alice <Dev> ";
console.log(renderName(sanitize(trimmed(raw))));
// console.log(renderName(raw)); // Error — string not assignable to Sanitized
Output:
<h1>Alice Dev</h1>
Once the brand is in place every downstream function takes Sanitized instead of string, so it is structurally impossible to call renderName with unsanitized input. This is the gold-standard way to encode security invariants in the type system.
Class-based brands with private fields
When you also want methods (isExpired, formatted) attached to the value, a class with a #brand private field gives you nominal typing and polymorphic methods. The cost is a boxed runtime object instead of a primitive.
class ApiKey {
#brand!: "ApiKey";
constructor(public readonly value: string) {
if (!value.startsWith("sk_")) throw new Error("invalid api key");
}
get masked(): string {
return `${this.value.slice(0, 4)}...${this.value.slice(-4)}`;
}
}
function callApi(k: ApiKey): void {
console.log(`using ${k.masked}`);
}
const k = new ApiKey("sk_live_abc123def456");
callApi(k);
// callApi("sk_live_abc123def456"); // Error — string is not ApiKey
Output:
using sk_l...f456
Private-field classes also benefit from instanceof checks at runtime, which makes them the right choice when you serialise across worker boundaries or need duck-typing protection.
Discriminated unions vs branded types
Discriminated unions and branded types both add "extra" type information to a value, but they solve different problems. The two are complementary — you frequently use both in the same module.
| Aspect | Discriminated union | Branded type |
|---|---|---|
| Runtime cost | Plain object with a tag field | Zero — same as base type |
| Construction | Object literal { kind: "x" } | Factory function or schema |
| Discriminates between | Variants of one logical type | Two different logical types of same base shape |
| Typical use | State machine, action types, Result | IDs, units, validated input, money |
| Narrowing | switch on tag | Function arg type alone |
| Mixing two values | Allowed if same union | Always a compile error |
Use a discriminated union when a value can be in one of several mutually-exclusive states. Use a brand when two values share the same shape but should never be mistaken for each other.
Helpers and utility library
A small set of reusable branded-type helpers is worth keeping in every codebase. The following library covers ~95% of use cases and is well under 40 lines.
// brand.ts
export type Brand<T, B extends string> = T & { readonly __brand: B };
export function brandFactory<B extends string>() {
return <T>(value: T): Brand<T, B> => value as Brand<T, B>;
}
export type Unbrand<T> = T extends Brand<infer U, string> ? U : T;
// usage
const makeUserId = brandFactory<"UserId">();
const makePostId = brandFactory<"PostId">();
type UserIdX = ReturnType<typeof makeUserId<string>>;
type PostIdX = ReturnType<typeof makePostId<string>>;
const ux: UserIdX = makeUserId("u_42");
const px: PostIdX = makePostId("p_99");
console.log(ux, px);
type Raw = Unbrand<UserIdX>; // string
Output:
u_42 p_99
Brands across boundaries
Brands erase at runtime, which means anything that crosses a serialization boundary (JSON.stringify, localStorage.setItem, postMessage) loses the brand and re-enters as plain string/number. Always re-validate at the boundary on the way back in.
type UserId3 = string & { readonly __brand: "UserId" };
function userId3(s: string): UserId3 { return s as UserId3; }
const u3 = userId3("u_42");
// Going out — brand erases
const wire = JSON.stringify({ id: u3 });
console.log(wire);
// Coming back — re-validate
const parsed = JSON.parse(wire) as { id: string };
// const wrong: UserId3 = parsed.id; // Error — plain string not assignable
const reparsed: UserId3 = userId3(parsed.id); // explicit re-brand
console.log(reparsed);
Output:
{"id":"u_42"}
u_42
This is by design: the brand is only as good as the validation behind its factory. Re-validate every time the value re-enters the trusted region of code.
Common pitfalls
- Casting bypasses the brand —
const bad = "foo" as UserIdcompiles. Centralise the cast in a factory function and lint againstas <BrandedType>everywhere else. - Forgetting to re-brand after deserialisation —
JSON.parsereturns plain types. Always pipe through the factory or Zod schema on the way in. - Adding methods to a branded primitive —
userId(s).toUpperCase()returnsstring, notUserId. Either wrap with the factory again or use a class-based brand. - Brand collision — two libraries that both brand a
stringwith"Id"will collide. Useunique symbolbrands or namespaced strings ("@myapp/UserId") for library code. as Brand<...>in hot paths — the brand erases, but the cast still appears in source review. Wrap once at the boundary; do not sprinkleascasts through business logic.- Discriminated-union with same brand — a union of branded variants only narrows on a real runtime discriminant. The brand alone is not visible at runtime, so don't try to switch on it.
- Brand drift in JSON shapes — adding a brand to an existing field can break tooling that expects plain
string(e.g., a database driver). Audit downstream consumers before adopting widely. - Forgetting to brand the output —
function trim(s: string): stringloses any input branding. Type the return asBrand<string, "Trimmed">to preserve the invariant. unique symboland structural compat — symbol-branded types are noisier in error messages. Trade off readability vs forgery resistance based on the audience.- Brands and class instances —
instanceofworks on classes, brands work on primitives. Don't mix unless you have a clear reason.
Real-world recipes
Recipe 1: typed primary keys with Zod
Validate a UUID and produce a branded UserId in one step. The brand prevents accidental mixups across the codebase; the schema guarantees the format.
import { z } from "zod";
const UserIdSchema = z.string().uuid().brand<"UserId">();
const OrderIdSchema = z.string().uuid().brand<"OrderId">();
type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;
function transferToOwner(orderId: OrderId, userId: UserId): string {
return `transfer order ${orderId} to user ${userId}`;
}
const u = UserIdSchema.parse("550e8400-e29b-41d4-a716-446655440000");
const o = OrderIdSchema.parse("8c5c4d2a-3b1e-4e2f-9a7c-1f6d7b9e0c3a");
console.log(transferToOwner(o, u));
// console.log(transferToOwner(u, o)); // Error — branded mismatch
Output:
transfer order 8c5c4d2a-3b1e-4e2f-9a7c-1f6d7b9e0c3a to user 550e8400-e29b-41d4-a716-446655440000
Recipe 2: SQL-safe strings
Brand a string as SqlSafe only via a parameterised-query builder. The renderer accepts only SqlSafe, so passing a raw user input is a compile error — defending against SQL injection at the type level.
type SqlSafe = string & { readonly __brand: "SqlSafe" };
function sql(strings: TemplateStringsArray, ...values: (string | number)[]): SqlSafe {
let out = "";
strings.forEach((s, i) => {
out += s;
if (i < values.length) {
out += typeof values[i] === "number"
? String(values[i])
: `'${String(values[i]).replace(/'/g, "''")}'`;
}
});
return out as SqlSafe;
}
function execute(query: SqlSafe): void {
console.log(`EXECUTE: ${query}`);
}
const name = "Alice O'Dev"; // contains a single quote
const id = 42;
execute(sql`SELECT * FROM users WHERE id = ${id} AND name = ${name}`);
// execute("SELECT * FROM users"); // Error — string is not SqlSafe
Output:
EXECUTE: SELECT * FROM users WHERE id = 42 AND name = 'Alice O''Dev'
Recipe 3: money arithmetic that respects currency
Branded money types that only allow same-currency math. Cross-currency conversions go through an explicit exchange function — no silent USD-plus-EUR bugs.
type Money<C extends string> = number & { readonly __currency: C };
type USD = Money<"USD">;
type EUR = Money<"EUR">;
const usd = (n: number) => n as USD;
const eur = (n: number) => n as EUR;
function addUsd(a: USD, b: USD): USD { return (a + b) as USD; }
function addEur(a: EUR, b: EUR): EUR { return (a + b) as EUR; }
function exchangeUsdToEur(amount: USD, rate: number): EUR {
return (amount * rate) as EUR;
}
const subtotal = usd(100);
const tax = usd(8.25);
const totalUsd = addUsd(subtotal, tax);
const totalEur = exchangeUsdToEur(totalUsd, 0.92);
console.log(`USD ${totalUsd.toFixed(2)} = EUR ${totalEur.toFixed(2)}`);
// addUsd(subtotal, eur(50)); // Error
Output:
USD 108.25 = EUR 99.59
Recipe 4: time-zone safe timestamps
Brands distinguish EpochMs (plain integer milliseconds) from IsoString (RFC 3339 string). Every function takes exactly the format it can handle, so no code accidentally pipes Date.now() into a function expecting an ISO string.
type EpochMs = number & { readonly __brand: "EpochMs" };
type IsoString = string & { readonly __brand: "IsoString" };
function nowEpoch(): EpochMs { return Date.now() as EpochMs; }
function toIso(ms: EpochMs): IsoString { return new Date(ms).toISOString() as IsoString; }
function fromIso(s: string): IsoString {
if (!/\d{4}-\d{2}-\d{2}T/.test(s)) throw new Error("bad iso");
return s as IsoString;
}
function logEvent(at: IsoString, name: string): void {
console.log(`[${at}] ${name}`);
}
const ts = nowEpoch();
logEvent(toIso(ts), "boot");
// logEvent(String(ts), "boot"); // Error — string not assignable
// logEvent(ts as any, "boot"); // bypassable, but obvious in review
Output:
[2026-05-25T00:00:00.000Z] boot
(The exact timestamp varies — the literal 2026-05-25T00:00:00.000Z above is illustrative.)
Recipe 5: branded React keys
When two different entity types share an ID column it is easy to render one with the wrong key prefix. Brand the keys and the JSX cannot type-check unless you use the right one.
type UserKey = string & { readonly __brand: "UserKey" };
type PostKey = string & { readonly __brand: "PostKey" };
function userKey(id: string): UserKey { return `user:${id}` as UserKey; }
function postKey(id: string): PostKey { return `post:${id}` as PostKey; }
interface UserRowProps { id: string; name: string }
interface PostRowProps { id: string; title: string }
function renderUser(props: UserRowProps & { key: UserKey }): string {
return `[${props.key}] ${props.name}`;
}
console.log(renderUser({ id: "u1", name: "Alice Dev", key: userKey("u1") }));
// renderUser({ id: "u1", name: "Alice Dev", key: postKey("u1") }); // Error
Output:
[user:u1] Alice Dev