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