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.

bash
# 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.

typescript
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

PatternWhere to use it
Hand-rolled { __brand: "X" }One-off brands, no extra dependency
unique symbol brandMaximum 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 #brandWhen 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.

typescript
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:

sql
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.

typescript
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:

sql
user u_42 deleted post p_99

The runtime representation is identical to a plain stringtypeof 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.

typescript
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:

code
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.

typescript
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:

css
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.

typescript
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:

sql
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).

typescript
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:

ini
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.

typescript
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:

bash
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.

typescript
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:

css
<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.

typescript
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:

arduino
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.

AspectDiscriminated unionBranded type
Runtime costPlain object with a tag fieldZero — same as base type
ConstructionObject literal { kind: "x" }Factory function or schema
Discriminates betweenVariants of one logical typeTwo different logical types of same base shape
Typical useState machine, action types, ResultIDs, units, validated input, money
Narrowingswitch on tagFunction arg type alone
Mixing two valuesAllowed if same unionAlways 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.

typescript
// 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:

code
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.

typescript
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:

json
{"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

  1. Casting bypasses the brandconst bad = "foo" as UserId compiles. Centralise the cast in a factory function and lint against as <BrandedType> everywhere else.
  2. Forgetting to re-brand after deserialisationJSON.parse returns plain types. Always pipe through the factory or Zod schema on the way in.
  3. Adding methods to a branded primitiveuserId(s).toUpperCase() returns string, not UserId. Either wrap with the factory again or use a class-based brand.
  4. Brand collision — two libraries that both brand a string with "Id" will collide. Use unique symbol brands or namespaced strings ("@myapp/UserId") for library code.
  5. as Brand<...> in hot paths — the brand erases, but the cast still appears in source review. Wrap once at the boundary; do not sprinkle as casts through business logic.
  6. 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.
  7. 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.
  8. Forgetting to brand the outputfunction trim(s: string): string loses any input branding. Type the return as Brand<string, "Trimmed"> to preserve the invariant.
  9. unique symbol and structural compat — symbol-branded types are noisier in error messages. Trade off readability vs forgery resistance based on the audience.
  10. Brands and class instancesinstanceof works 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.

typescript
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:

css
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.

typescript
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:

sql
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.

typescript
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:

ini
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.

typescript
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:

csharp
[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.

typescript
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:

csharp
[user:u1] Alice Dev