|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/branded-types
$cat docs/typescript-—-branded-&-opaque-types.md
updated Today·20 min read·published

TypeScript — Branded & Opaque Types

TypeScriptAdvancedValidationAdvanced🎯Free Tools
Introduction

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

Brand at trust boundaries (parse, DB, auth). Keep brands off unvalidated strings.
The Classic Brand Pattern
brand.ts
TypeScript
1declare const __brand: unique symbol;
2
3type Brand<T, B extends string> = T & { readonly [__brand]: B };
4
5type UserId = Brand<string, "UserId">;
6type OrderId = Brand<string, "OrderId">;
7type Email = Brand<string, "Email">;
8
9function UserId(raw: string): UserId {
10 if (!raw) throw new Error("empty id");
11 return raw as UserId;
12}
13
14function getUser(id: UserId): void {
15 console.log(id);
16}
17
18const uid = UserId("u_123");
19const oid = "o_9" as OrderId;
20
21getUser(uid); // OK
22// getUser(oid); // ERROR — OrderId not assignable to UserId
23// getUser("u_123"); // ERROR — plain string not branded
24
ApproachRuntime costErgonomics
Intersection brandNone (erase)Simple; needs constructors
Unique symbol brandNoneStronger nominal feel
Class wrapperObject allocTrue nominal; heavier
Zod .brand()Parse costValidates + brands
Smart Constructors & Validation

Never cast arbitrary strings to brands in application code. Provide a single constructor (or parser) that validates invariants, then brands the value.

email.ts
TypeScript
1type Email = Brand<string, "Email">;
2
3function 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
11function tryParseEmail(input: string): Email | null {
12 try {
13 return parseEmail(input);
14 } catch {
15 return null;
16 }
17}
18
19// API handler
20function 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

Scattered as UserId casts defeat the pattern. Treat illegal casts like any — require review.
Zod Brand

Zod’s .brand<"Name">() attaches a brand to the inferred type after successful parse — ideal for API and env boundaries.

zod-brand.ts
TypeScript
1import { z } from "zod";
2
3const UserId = z.string().uuid().brand<"UserId">();
4type UserId = z.infer<typeof UserId>;
5
6const OrderId = z.string().min(1).brand<"OrderId">();
7type OrderId = z.infer<typeof OrderId>;
8
9const CreateOrder = z.object({
10 userId: UserId,
11 sku: z.string().min(1),
12});
13
14type CreateOrder = z.infer<typeof CreateOrder>;
15
16function handle(req: unknown) {
17 const data = CreateOrder.parse(req);
18 // data.userId is branded — cannot pass as OrderId
19 return data;
20}
21
22const parsed = UserId.safeParse("not-a-uuid");
23if (!parsed.success) {
24 console.error(parsed.error.flatten());
25}
26

best practice

One schema owns validation + branding. Export z.infer types; do not redefine parallel interfaces.
Numeric & Unit Brands
units.ts
TypeScript
1type UsdCents = Brand<number, "UsdCents">;
2type Watts = Brand<number, "Watts">;
3type Celsius = Brand<number, "Celsius">;
4
5function UsdCents(n: number): UsdCents {
6 if (!Number.isInteger(n) || n < 0) throw new Error("cents");
7 return n as UsdCents;
8}
9
10function 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.

Unwrapping & Interop
unwrap.ts
TypeScript
1type UserId = Brand<string, "UserId">;
2
3/** Explicit escape hatch for DB drivers / logs */
4function unwrapId(id: UserId): string {
5 return id; // brand is phantom — structurally a string
6}
7
8function 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
14function fromJson(data: unknown): UserId {
15 return UserId(String(data)); // your constructor
16}
17
True Nominal Alternatives
PatternWhen
Brand intersectionIDs, emails, tokens — default choice
private constructor classNeed runtime instanceof + methods
Enum-like const objectsClosed string sets (prefer unions)
Flattened DTOs + ZodWire formats — brand after parse
class-nominal.ts
TypeScript
1class 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
Validation Boundaries Map

Draw a hard line: untrusted unknown → parse → branded domain types → pure business logic → unwrap at IO.

boundary.ts
TypeScript
1type UserId = Brand<string, "UserId">;
2type Email = Brand<string, "Email">;
3
4interface User {
5 id: UserId;
6 email: Email;
7}
8
9// Edge
10function 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
19function welcome(user: User): string {
20 return `Hello ${user.email}`;
21}
22
🔥

pro tip

Name constructors like types (UserId(...)) so call sites read as domain language.
Pitfalls
pitfalls.ts
TypeScript
1type UserId = Brand<string, "UserId">;
2
3// ❌ Branding without validation
4const id = "not-uuid" as UserId;
5
6// ❌ Parallel type aliases without brand
7type UserIdAlias = string; // interchangeable with OrderIdAlias
8
9// ❌ Spreading brands into mutable APIs that accept string
10function legacy(id: string) {}
11declare const uid: UserId;
12legacy(uid); // works — OK for output; don't accept string and cast back casually
13
14// ❌ Object brands that appear in keyof accidentally
15type Bad = { id: string } & { __brand: "User" };
16type Keys = keyof Bad; // includes __brand if not careful with unique symbol
17
Practice Snippets

Snippet 1 — Basic brand

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

brand-1.ts
TypeScript
1declare const B: unique symbol;
2type Brand<T, S extends string> = T & { [B]: S };
3type A = Brand<string, "A">;
4type C = Brand<string, "C">;
5type Eq = A extends C ? true : false;

Snippet 2 — Constructor

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

brand-2.ts
TypeScript
1type UserId = string & { readonly __brand: "UserId" };
2const UserId = (s: string) => s as UserId;
3const x = UserId("1");
4type T = typeof x;

Snippet 3 — Zod brand infer

Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.

brand-3.ts
TypeScript
1import { z } from "zod";
2const S = z.string().brand<"Tok">();
3type Tok = z.infer<typeof S>;

Snippet 4 — Money add

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

brand-4.ts
TypeScript
1type Cents = number & { __b: "Cents" };
2const 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.

brand-5.ts
TypeScript
1type Id = string & { __b: "Id" };
2declare 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.

brand-6.ts
TypeScript
1type Email = string & { __b: "Email" };
2function 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.

brand-7.ts
TypeScript
1type Id = string & { __b: "Id" };
2const unwrap = (id: Id): string => id;

Snippet 8 — Two ids

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

brand-8.ts
TypeScript
1type U = string & { __b: "U" };
2type O = string & { __b: "O" };
3type 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.

brand-9.ts
TypeScript
1type Id = string & { __b: "Id" };
2const id = "1" as Id;
3const 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.

brand-10.ts
TypeScript
1type UserId = string & { __b: "UserId" };
2type MapUsers = Record<UserId, { name: string }>;
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
Deep Dive — Brand Hygiene

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.

hygiene.ts
TypeScript
1declare const __brand: unique symbol;
2type Brand<T, B extends string> = T & { readonly [__brand]: B };
3
4type UserId = Brand<string, "UserId">;
5type SessionToken = Brand<string, "SessionToken">;
6
7/** Only auth package may construct SessionToken */
8export 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 */
15export function authorize(token: SessionToken, userId: UserId): boolean {
16 return token.length > 0 && userId.length > 0;
17}
18
19// Anti-pattern centralization
20export type AssertBrand = never; // document: no `as SessionToken` outside this file

Flattened brands for objects

object-brand.ts
TypeScript
1type BrandedObject<T, B extends string> = T & { readonly [__brand]: B };
2
3type VerifiedAddress = BrandedObject<
4 { line1: string; city: string; postal: string },
5 "VerifiedAddress"
6>;
7
8function 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
17function shipTo(addr: VerifiedAddress) {
18 return addr.city;
19}

warning

Object brands still structurally share fields with the unbranded shape. The brand only blocks assignment when the phantom field is required — keep using unique symbol keys.
Form Validation Pipeline

Wire form libraries so parsed output is branded. Keep the input type as plain strings; only the submit handler sees brands.

form-pipeline.ts
TypeScript
1import { z } from "zod";
2
3const SignupInput = z.object({
4 email: z.string().email().brand<"Email">(),
5 password: z.string().min(12).brand<"PasswordHashInput">(),
6});
7
8type SignupInput = z.infer<typeof SignupInput>;
9
10async 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}
LayerTypesAllowed casts
HTTP / formsunknownNone — parse only
ParsersBranded outputsSingle as Brand after checks
DomainBrands onlyForbidden
DB / logsPrimitivesExplicit unwrap helpers
Testing with Brands

In tests, either call real constructors or expose a TestBrands helper gated by a comment / lint exception. Prefer real parsers so tests document invariants.

test-brands.ts
TypeScript
1import { describe, it, expect } from "vitest";
2
3describe("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

If production constructors are expensive, add UserId.unsafe used only under NODE_ENV === "test".
Summary

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

Start with UserId, Email, and money units — those three prevent an entire class of production bugs.
$Blueprint — Engineering Documentation·Section ID: TS-BRANDED·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.