TypeScript — Branded & Opaque Types
TypeScript’s type system is structural: two types with the same shape are interchangeable. That is great for objects, but dangerous for primitives — UserId and OrderId are both strings at runtime, and the compiler will happily swap them.
Branded (opaque) types add a phantom compile-time tag so distinct domains stay distinct, while remaining zero-cost at runtime when you use simple intersections.
info
| 1 | declare const __brand: unique symbol; |
| 2 | |
| 3 | type Brand<T, B extends string> = T & { readonly [__brand]: B }; |
| 4 | |
| 5 | type UserId = Brand<string, "UserId">; |
| 6 | type OrderId = Brand<string, "OrderId">; |
| 7 | type Email = Brand<string, "Email">; |
| 8 | |
| 9 | function UserId(raw: string): UserId { |
| 10 | if (!raw) throw new Error("empty id"); |
| 11 | return raw as UserId; |
| 12 | } |
| 13 | |
| 14 | function getUser(id: UserId): void { |
| 15 | console.log(id); |
| 16 | } |
| 17 | |
| 18 | const uid = UserId("u_123"); |
| 19 | const oid = "o_9" as OrderId; |
| 20 | |
| 21 | getUser(uid); // OK |
| 22 | // getUser(oid); // ERROR — OrderId not assignable to UserId |
| 23 | // getUser("u_123"); // ERROR — plain string not branded |
| 24 |
| Approach | Runtime cost | Ergonomics |
|---|---|---|
| Intersection brand | None (erase) | Simple; needs constructors |
| Unique symbol brand | None | Stronger nominal feel |
| Class wrapper | Object alloc | True nominal; heavier |
| Zod .brand() | Parse cost | Validates + brands |
Never cast arbitrary strings to brands in application code. Provide a single constructor (or parser) that validates invariants, then brands the value.
| 1 | type Email = Brand<string, "Email">; |
| 2 | |
| 3 | function parseEmail(input: string): Email { |
| 4 | const trimmed = input.trim().toLowerCase(); |
| 5 | if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) { |
| 6 | throw new Error(`Invalid email: ${input}`); |
| 7 | } |
| 8 | return trimmed as Email; |
| 9 | } |
| 10 | |
| 11 | function tryParseEmail(input: string): Email | null { |
| 12 | try { |
| 13 | return parseEmail(input); |
| 14 | } catch { |
| 15 | return null; |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | // API handler |
| 20 | function createUser(body: unknown) { |
| 21 | const emailRaw = String((body as { email?: string }).email ?? ""); |
| 22 | const email = parseEmail(emailRaw); // brand only after validation |
| 23 | return { email }; |
| 24 | } |
| 25 |
danger
Zod’s .brand<"Name">() attaches a brand to the inferred type after successful parse — ideal for API and env boundaries.
| 1 | import { z } from "zod"; |
| 2 | |
| 3 | const UserId = z.string().uuid().brand<"UserId">(); |
| 4 | type UserId = z.infer<typeof UserId>; |
| 5 | |
| 6 | const OrderId = z.string().min(1).brand<"OrderId">(); |
| 7 | type OrderId = z.infer<typeof OrderId>; |
| 8 | |
| 9 | const CreateOrder = z.object({ |
| 10 | userId: UserId, |
| 11 | sku: z.string().min(1), |
| 12 | }); |
| 13 | |
| 14 | type CreateOrder = z.infer<typeof CreateOrder>; |
| 15 | |
| 16 | function handle(req: unknown) { |
| 17 | const data = CreateOrder.parse(req); |
| 18 | // data.userId is branded — cannot pass as OrderId |
| 19 | return data; |
| 20 | } |
| 21 | |
| 22 | const parsed = UserId.safeParse("not-a-uuid"); |
| 23 | if (!parsed.success) { |
| 24 | console.error(parsed.error.flatten()); |
| 25 | } |
| 26 |
best practice
| 1 | type UsdCents = Brand<number, "UsdCents">; |
| 2 | type Watts = Brand<number, "Watts">; |
| 3 | type Celsius = Brand<number, "Celsius">; |
| 4 | |
| 5 | function UsdCents(n: number): UsdCents { |
| 6 | if (!Number.isInteger(n) || n < 0) throw new Error("cents"); |
| 7 | return n as UsdCents; |
| 8 | } |
| 9 | |
| 10 | function addMoney(a: UsdCents, b: UsdCents): UsdCents { |
| 11 | return (a + b) as UsdCents; // arithmetic widens to number — re-brand intentionally |
| 12 | } |
| 13 | |
| 14 | // const oops = addMoney(UsdCents(100), 5 as Watts); // ERROR |
| 15 |
Arithmetic and string concatenation erase brands (results are plain number / string). Re-apply brands only in helpers that preserve invariants.
| 1 | type UserId = Brand<string, "UserId">; |
| 2 | |
| 3 | /** Explicit escape hatch for DB drivers / logs */ |
| 4 | function unwrapId(id: UserId): string { |
| 5 | return id; // brand is phantom — structurally a string |
| 6 | } |
| 7 | |
| 8 | function loadRow(id: UserId) { |
| 9 | const sql = `SELECT * FROM users WHERE id = $1`; |
| 10 | return { sql, params: [unwrapId(id)] }; |
| 11 | } |
| 12 | |
| 13 | // JSON: brands do not survive stringify/parse — re-validate on the way back in |
| 14 | function fromJson(data: unknown): UserId { |
| 15 | return UserId(String(data)); // your constructor |
| 16 | } |
| 17 |
| Pattern | When |
|---|---|
| Brand intersection | IDs, emails, tokens — default choice |
| private constructor class | Need runtime instanceof + methods |
| Enum-like const objects | Closed string sets (prefer unions) |
| Flattened DTOs + Zod | Wire formats — brand after parse |
| 1 | class UserIdClass { |
| 2 | private constructor(readonly value: string) {} |
| 3 | static create(raw: string): UserIdClass { |
| 4 | if (!raw.startsWith("u_")) throw new Error("prefix"); |
| 5 | return new UserIdClass(raw); |
| 6 | } |
| 7 | toString() { |
| 8 | return this.value; |
| 9 | } |
| 10 | } |
| 11 | |
| 12 | // Structural typing still applies to public shape — private fields help nominality |
| 13 |
Draw a hard line: untrusted unknown → parse → branded domain types → pure business logic → unwrap at IO.
| 1 | type UserId = Brand<string, "UserId">; |
| 2 | type Email = Brand<string, "Email">; |
| 3 | |
| 4 | interface User { |
| 5 | id: UserId; |
| 6 | email: Email; |
| 7 | } |
| 8 | |
| 9 | // Edge |
| 10 | function httpCreateUser(body: unknown): User { |
| 11 | const schema = z.object({ |
| 12 | id: z.string().uuid().brand<"UserId">(), |
| 13 | email: z.string().email().brand<"Email">(), |
| 14 | }); |
| 15 | return schema.parse(body); |
| 16 | } |
| 17 | |
| 18 | // Core — only branded types |
| 19 | function welcome(user: User): string { |
| 20 | return `Hello ${user.email}`; |
| 21 | } |
| 22 |
pro tip
| 1 | type UserId = Brand<string, "UserId">; |
| 2 | |
| 3 | // ❌ Branding without validation |
| 4 | const id = "not-uuid" as UserId; |
| 5 | |
| 6 | // ❌ Parallel type aliases without brand |
| 7 | type UserIdAlias = string; // interchangeable with OrderIdAlias |
| 8 | |
| 9 | // ❌ Spreading brands into mutable APIs that accept string |
| 10 | function legacy(id: string) {} |
| 11 | declare const uid: UserId; |
| 12 | legacy(uid); // works — OK for output; don't accept string and cast back casually |
| 13 | |
| 14 | // ❌ Object brands that appear in keyof accidentally |
| 15 | type Bad = { id: string } & { __brand: "User" }; |
| 16 | type Keys = keyof Bad; // includes __brand if not careful with unique symbol |
| 17 |
Snippet 1 — Basic brand
Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.
| 1 | declare const B: unique symbol; |
| 2 | type Brand<T, S extends string> = T & { [B]: S }; |
| 3 | type A = Brand<string, "A">; |
| 4 | type C = Brand<string, "C">; |
| 5 | type Eq = A extends C ? true : false; |
Snippet 2 — Constructor
Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type UserId = string & { readonly __brand: "UserId" }; |
| 2 | const UserId = (s: string) => s as UserId; |
| 3 | const x = UserId("1"); |
| 4 | type T = typeof x; |
Snippet 3 — Zod brand infer
Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.
| 1 | import { z } from "zod"; |
| 2 | const S = z.string().brand<"Tok">(); |
| 3 | type Tok = z.infer<typeof S>; |
Snippet 4 — Money add
Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type Cents = number & { __b: "Cents" }; |
| 2 | const add = (a: Cents, b: Cents) => (a + b) as Cents; |
Snippet 5 — Reject plain string
Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type Id = string & { __b: "Id" }; |
| 2 | declare function f(id: Id): void; |
| 3 | // f("x"); |
Snippet 6 — Email parse
Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type Email = string & { __b: "Email" }; |
| 2 | function parse(s: string): Email { |
| 3 | if (!s.includes("@")) throw new Error("e"); |
| 4 | return s as Email; |
| 5 | } |
Snippet 7 — Unwrap
Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type Id = string & { __b: "Id" }; |
| 2 | const unwrap = (id: Id): string => id; |
Snippet 8 — Two ids
Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type U = string & { __b: "U" }; |
| 2 | type O = string & { __b: "O" }; |
| 3 | type X = U extends O ? 1 : 0; |
Snippet 9 — JSON roundtrip
Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type Id = string & { __b: "Id" }; |
| 2 | const id = "1" as Id; |
| 3 | const raw = JSON.parse(JSON.stringify(id)); |
| 4 | // typeof raw is any/unknown-ish — must re-brand |
Snippet 10 — Record keys
Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type UserId = string & { __b: "UserId" }; |
| 2 | type MapUsers = Record<UserId, { name: string }>; |
note
Treat brands as capabilities conferred by validation. A module that creates brands without checking invariants is a security bug waiting for a confused-deputy string mix-up.
| 1 | declare const __brand: unique symbol; |
| 2 | type Brand<T, B extends string> = T & { readonly [__brand]: B }; |
| 3 | |
| 4 | type UserId = Brand<string, "UserId">; |
| 5 | type SessionToken = Brand<string, "SessionToken">; |
| 6 | |
| 7 | /** Only auth package may construct SessionToken */ |
| 8 | export function issueToken(bytes: Uint8Array): SessionToken { |
| 9 | const hex = Buffer.from(bytes).toString("hex"); |
| 10 | if (hex.length < 32) throw new Error("entropy"); |
| 11 | return hex as SessionToken; |
| 12 | } |
| 13 | |
| 14 | /** Application code consumes — never casts */ |
| 15 | export function authorize(token: SessionToken, userId: UserId): boolean { |
| 16 | return token.length > 0 && userId.length > 0; |
| 17 | } |
| 18 | |
| 19 | // Anti-pattern centralization |
| 20 | export type AssertBrand = never; // document: no `as SessionToken` outside this file |
Flattened brands for objects
| 1 | type BrandedObject<T, B extends string> = T & { readonly [__brand]: B }; |
| 2 | |
| 3 | type VerifiedAddress = BrandedObject< |
| 4 | { line1: string; city: string; postal: string }, |
| 5 | "VerifiedAddress" |
| 6 | >; |
| 7 | |
| 8 | function verifyAddress(raw: { |
| 9 | line1: string; |
| 10 | city: string; |
| 11 | postal: string; |
| 12 | }): VerifiedAddress { |
| 13 | if (!raw.postal.trim()) throw new Error("postal"); |
| 14 | return raw as VerifiedAddress; |
| 15 | } |
| 16 | |
| 17 | function shipTo(addr: VerifiedAddress) { |
| 18 | return addr.city; |
| 19 | } |
warning
Wire form libraries so parsed output is branded. Keep the input type as plain strings; only the submit handler sees brands.
| 1 | import { z } from "zod"; |
| 2 | |
| 3 | const SignupInput = z.object({ |
| 4 | email: z.string().email().brand<"Email">(), |
| 5 | password: z.string().min(12).brand<"PasswordHashInput">(), |
| 6 | }); |
| 7 | |
| 8 | type SignupInput = z.infer<typeof SignupInput>; |
| 9 | |
| 10 | async function onSubmit(raw: unknown) { |
| 11 | const parsed = SignupInput.safeParse(raw); |
| 12 | if (!parsed.success) return { ok: false as const, error: parsed.error }; |
| 13 | const { email, password } = parsed.data; |
| 14 | // hash password → PasswordHash brand in crypto module |
| 15 | return { ok: true as const, email }; |
| 16 | } |
| Layer | Types | Allowed casts |
|---|---|---|
| HTTP / forms | unknown | None — parse only |
| Parsers | Branded outputs | Single as Brand after checks |
| Domain | Brands only | Forbidden |
| DB / logs | Primitives | Explicit unwrap helpers |
In tests, either call real constructors or expose a TestBrands helper gated by a comment / lint exception. Prefer real parsers so tests document invariants.
| 1 | import { describe, it, expect } from "vitest"; |
| 2 | |
| 3 | describe("UserId", () => { |
| 4 | it("accepts uuid", () => { |
| 5 | const id = UserId("550e8400-e29b-41d4-a716-446655440000"); |
| 6 | expect(unwrapId(id)).toContain("-"); |
| 7 | }); |
| 8 | |
| 9 | it("rejects empty", () => { |
| 10 | expect(() => UserId("")).toThrow(); |
| 11 | }); |
| 12 | }); |
info
Branded types recover nominal distinctions for primitives without runtime wrappers. Pair them with Zod (or hand-written parsers), ban casual assertions, and unwrap only at IO boundaries.
pro tip
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.