Articles

TypeScript Branded Types Explained

Branded types attach a compile-time-only tag to a primitive so TypeScript rejects mixing values that share a shape but not a meaning.

Takina Takina · · 5 min read
Dark code editor with syntax-highlighted TypeScript

A branded type (also called a nominal type or opaque type) is a technique for making TypeScript treat two values with the same underlying shape as different types, by attaching a fake compile-time-only property that only exists in the type system. It solves a specific gap in TypeScript’s design: structural typing means any two strings are interchangeable, even if one is a UserId and the other is an EmailAddress. Branding closes that gap without changing anything at runtime.

The problem branding solves

TypeScript uses structural typing: a value is compatible with a type if it has the right shape, regardless of how it was created or what it’s conceptually supposed to represent. That’s usually a strength — see TypeScript interfaces vs types for how structural compatibility works — but it has a sharp edge with primitives.

type UserId = string;
type OrderId = string;

function getUser(id: UserId) { /* ... */ }

const orderId: OrderId = "ord_123";
getUser(orderId); // compiles fine — both are just `string`

Both UserId and OrderId are type aliases for string, so the compiler sees no difference between them. Passing an order ID where a user ID belongs is a real bug, and TypeScript won’t catch it, because structurally they’re identical.

How to brand a type

The standard pattern intersects the primitive with an object type carrying a unique, unused property — the “brand”:

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function getUser(id: UserId) { /* ... */ }

const orderId = "ord_123" as OrderId;
getUser(orderId); // error: OrderId is not assignable to UserId

The __brand property never exists at runtime — no object literal actually has it, and no code checks for it. It’s purely a marker the type checker uses to tell UserId and OrderId apart, even though both are structurally string underneath. Because it’s fictional, you can’t construct a branded value with a plain literal; you have to explicitly assert it with as, which is the point — it forces you to go through a single, deliberate conversion path instead of letting any string slip in silently.

A common refinement uses a generic helper so you don’t repeat the intersection everywhere:

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
type PositiveInt = Brand<number, "PositiveInt">;

This pairs well with a smart constructor — a function that validates input and only then produces the branded value:

function parseEmail(input: string): Email | null {
  if (!input.includes("@")) return null;
  return input as Email;
}

Once a value has been through parseEmail, its type carries the guarantee that it passed validation. Any function that only accepts Email can trust that guarantee without re-validating — the type itself is the proof. This is a lightweight version of the “parse, don’t validate” idea: validation happens once, at the boundary, and the type system remembers the result.

Where branding earns its keep

Branded types are most valuable where structurally identical primitives carry meaningfully different semantics:

  • IDs across entities — UserId, OrderId, ProductId are all strings or numbers, but mixing them is a logic bug, not a type error, unless branded.
  • Units — Meters vs Feet, or Cents vs Dollars, where a bare number gives no protection against adding the wrong two quantities together.
  • Validated vs unvalidated data — a branded SanitizedHtml distinct from string documents, at the type level, that a value has already passed a specific check. This pairs naturally with the discipline described in what is a content security policy, where trusting the wrong string is a real vulnerability, not just an inconvenience.
  • Currency or locale-tagged strings — distinguishing an ISO date string that’s already been normalized from one that hasn’t.

It’s less useful for values that genuinely are interchangeable, or for internal helper types where the extra ceremony doesn’t pay for itself. Branding a type you only ever use in one place is pure overhead.

Branding vs other TypeScript narrowing tools

Branded types are a modeling technique, not a competitor to TypeScript’s built-in narrowing features — they’re often combined:

TechniquePurposeRuntime cost
Branded typesDistinguish structurally identical typesNone — compile-time only
Type guardsNarrow a union based on a runtime checkRuns a real check
Discriminated unionsDistinguish variants of a tagged objectNone extra — tag is real data
GenericsParameterize types over other typesNone
unknown vs anyForce validation before useNone directly, but enables it

A discriminated union’s tag is a real, runtime-readable field — you check value.kind === "circle" and the check actually happens. A brand is the opposite: it’s erased entirely at compile time, so it can only encode invariants that were established once, elsewhere, such as inside a smart constructor. Branding also composes with utility types like Omit and Pick, since the brand is just another property in the intersection.

A note on the unique symbol alternative

Some codebases use declare const brand: unique symbol instead of a string literal for the tag, which guarantees the brand key itself can’t collide with a real property name, even accidentally. For most projects the simpler string-literal brand shown above is sufficient — reach for unique symbol only if you’re building a shared library where brand collisions across packages are a realistic risk.

The takeaway

Branded types close a real gap in TypeScript’s structural type system: they let you make UserId and OrderId, or Meters and Feet, incompatible even though both are, underneath, the same primitive. The brand is erased at runtime and enforced only by the compiler, so the payoff is entirely in catching mixing bugs during development. Reach for branding when structurally identical values carry different meanings and mixing them would be a real bug — not as a default habit for every type alias in your codebase.

Takina Takina · · 4 min read

Structural Typing vs Nominal Typing in TypeScript

Structural typing checks shape, not name — TypeScript treats two differently-named types as compatible if their members match, unlike nominal systems.

#TypeScript #JavaScript #Web Development
Takina Takina · · 5 min read

TypeScript Variance Explained

Variance decides when TypeScript accepts Array<Dog> where Array<Animal> is expected — covariant, contravariant, or invariant, explained with examples.

#TypeScript #JavaScript #Web Development
Takina Takina · · 5 min read

Zod vs Yup: TypeScript Schema Validation Compared

Zod infers static TypeScript types directly from its schemas; Yup was built for JavaScript form validation first. How the two approaches differ.

#TypeScript #JavaScript #Web Development