DEV Community

Cover image for Branded Types in TypeScript: Cheap Type Safety You Should Already Be Using
Gabriel Anhaia
Gabriel Anhaia

Posted on

Branded Types in TypeScript: Cheap Type Safety You Should Already Be Using


A function takes a userId: string and an orderId: string.
During a refactor, two arguments cross. The new call reads
refund(orderId, userId) instead of refund(userId, orderId).
The checker shrugs. Both are strings. Both pass. The unit test for
refund ran against a happy-path fixture where the IDs happened
to be different lengths, so the SQL still returned a row. Just
the wrong one. A customer reads someone else's order history
before the on-call notices.

This bug is older than TypeScript. Languages with nominal type
systems can declare a UserId distinct from every other string,
and the compiler refuses to confuse them. TypeScript is
structural. A string is a string. Two type aliases that
resolve to the same primitive are the same type.

The workaround takes about twelve characters of declaration and
adds zero runtime overhead. You can ship it this afternoon.

The Setup That Catches Argument Swaps

Branded types add a phantom property to a primitive. The property
exists in the type system. It does not exist at runtime. Two strings
with different brands cannot be assigned to each other, even though
they are both still strings under the hood.

The minimal form is a one-liner per ID type.

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

function refund(user: UserId, order: OrderId): void {
  // ...
}

declare const u: UserId;
declare const o: OrderId;

refund(u, o); // ok
refund(o, u); // error
Enter fullscreen mode Exit fullscreen mode

That second call now fails to type-check. The error message points
at the argument position where the swap happened, in the file where
the bug is, on the keystroke after the typo. The checker catches it
before any lint rule, test, or review comment runs.

The intersection with { readonly __brand: "UserId" } is the
entire trick. At runtime, a UserId is a plain string. The
__brand field does not exist at runtime. Its job is to make
UserId and OrderId structurally distinct so the assignability
check fails. The field is fiction. The compiler pretends it
exists; runtime never sees it.

The cost is at the boundaries: you can no longer pass a raw string
where a UserId is expected. Every place a string crosses into
the typed domain needs a constructor.

function userId(s: string): UserId {
  // Validate, then assert. The cast is the explicit
  // commitment that everything past this line treats s
  // as a UserId.
  if (!/^u_[A-Za-z0-9]{20}$/.test(s)) {
    throw new Error(`bad UserId: ${s}`);
  }
  return s as UserId;
}

const u = userId(req.params.id);
Enter fullscreen mode Exit fullscreen mode

That cast is the only place in the codebase where a string turns
into a UserId. Everything downstream trusts the constructor
validated it. Every place that needs a UserId from a string goes
through a constructor like this one. The brand is the type system's
way of forcing every entry point into the typed domain to be
visible.

Pattern 2: A Reusable Brand<T, Tag> Helper

Hand-rolling that intersection for every ID gets old fast. The fix
is one helper:

declare const __brand: unique symbol;

type Brand<T, Tag extends string> = T & {
  readonly [__brand]: Tag;
};
Enter fullscreen mode Exit fullscreen mode

__brand is declared as a unique symbol, which is a value the
type system promises is distinct from every other symbol in the
program. A property keyed by a unique symbol cannot be reproduced
by accident. Two Brand<string, "UserId"> values will be
assignable to each other (same tag, same underlying type), but a
Brand<string, "UserId"> and a Brand<string, "OrderId"> will not.

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;

type Cents = Brand<number, "Cents">;
type Millis = Brand<number, "Millis">;

function chargeCard(user: UserId, amount: Cents): void {
  // ...
}

declare const u: UserId;
declare const e: Email;
declare const c: Cents;
declare const m: Millis;

chargeCard(u, c); // ok
chargeCard(e, c); // error: Email is not UserId
chargeCard(u, m); // error: Millis is not Cents
Enter fullscreen mode Exit fullscreen mode

The number cases are where brands matter beyond IDs. A function
that takes a duration in milliseconds and a function that takes a
duration in seconds both have signature (n: number) => void. The
compiler will let you swap them all day. Brand the duration types
and the swap stops compiling.

The constructor pattern from before works unchanged. email(s)
validates the regex and casts to Email. cents(n) validates the
integer and casts to Cents. Unbranding rarely matters.
JSON.stringify ignores the symbol key. SQL drivers see the
underlying string. Boundary code rarely touches the primitive.

Pattern 3: unique symbol Tags for Strict Nominal Behavior

The Brand<T, Tag> helper above already uses a unique symbol for
its key. The third pattern goes one step further and uses a
unique symbol for the tag itself, not just the key.

declare const userIdTag: unique symbol;
declare const orderIdTag: unique symbol;

type UserId = string & { readonly [userIdTag]: true };
type OrderId = string & { readonly [orderIdTag]: true };
Enter fullscreen mode Exit fullscreen mode

With string tags, two modules that both write
Brand<string, "UserId"> produce the same type and are
interchangeable. With unique symbol tags, every
declare const ... : unique symbol in every module produces a
distinct symbol. A UserId declared in auth/types.ts and a
UserId declared in users/types.ts are different types, even
spelled the same way. The compiler refuses to mix them.

For most codebases this is too strict; you want one canonical
UserId shared across the app. The unique-symbol tag matters for
published library code (so consumers cannot guess the string tag
and produce values of the package's brand) and for high-stakes
domains: financial amounts, encryption keys, capability tokens.

If you go this route, keep the symbol private to the constructor
module so the cast is the only way to produce the value.

// In money.ts — the only module that imports the symbol.
declare const cents: unique symbol;
export type Cents = number & { readonly [cents]: true };

export function asCents(n: number): Cents {
  if (!Number.isInteger(n) || n < 0) {
    throw new Error(`bad cents: ${n}`);
  }
  return n as Cents;
}
Enter fullscreen mode Exit fullscreen mode

Other modules import Cents and asCents. They cannot import the
private symbol. The brand is hermetic: every Cents value in the
codebase came through asCents.

What Zod Already Does for You

If you reach for Zod at the boundary (and most
TypeScript apps do — Zod sits at tens of millions of weekly npm
downloads
as of April 2026),
the brand operator is built in.

import { z } from "zod";

const UserIdSchema = z.uuid().brand<"UserId">();
const OrderIdSchema = z.uuid().brand<"OrderId">();

type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;

function refund(user: UserId, order: OrderId): void {
  // ...
}

const u = UserIdSchema.parse(req.params.userId);
const o = OrderIdSchema.parse(req.params.orderId);

refund(u, o); // ok
refund(o, u); // error
Enter fullscreen mode Exit fullscreen mode

z.uuid().brand<"UserId">() does the validation and the brand
assignment in one call. The inferred TypeScript type carries an
opaque BRAND marker that behaves the same way as the hand-rolled
brand. If you already validate inputs with Zod, adding
.brand<"...">() is the smallest possible step in: every value
that comes back from a Zod parse is already branded; every function
downstream takes the branded type. No separate constructor file to
maintain. Effect Schema and Valibot expose similar operators; Yup
users typically reach for an intersection helper since it does not
ship a first-class brand operator.

Many codebases run both: Zod brands at HTTP and queue boundaries,
hand-rolled brands for purely internal types that never get
serialized (a RowVersion, an Etag, a Hash).

When Brands Earn Their Keep

Branded types are not free. Every constructor is a function call
to write and a place values can throw. Every signature that takes
a brand requires the caller to produce that brand. In a
mostly-string utility codebase, that overhead is real.

Brands repay their cost in a few specific shapes:

  • ID-heavy domains. A reducer that takes userId, orderId, productId, tenantId in different combinations is the textbook case. Argument-swap bugs scale with the number of same-typed parameters per function.
  • Unit-bearing numbersCents versus Dollars, Millis versus Seconds, Bytes versus Megabytes. Bug postmortems regularly blame unit confusion; the Mars Climate Orbiter loss is the canonical public example, but smaller versions show up in any system that mixes durations or currencies.
  • Validated strings. An Email, a URL, a Sha256Hash. The brand is proof that this string came through the validator.
  • Capability boundariesRawHtml versus SafeHtml, RawSql versus ParameterizedSql, EncryptedToken versus DecryptedToken. The brand encodes "this value has been processed by the security layer."

Brands stop paying off in glue scripts and one-off utilities, pure
numeric computation without IDs, and modules whose job is mostly
shovelling strings into a logger or HTTP client. The honest test is
to count same-typed parameter pairs. A codebase with hundreds of
those pairs is where brands become preventive medicine. One with
five is where the overhead wins.

Pick One Shape and Stick With It

The three patterns are not a hierarchy. The string-tag
Brand<T, Tag> helper covers most of what most codebases need.
The unique symbol-tag form is for libraries and high-stakes
domains. Zod brands belong at the boundaries you already validate.
Pick one as the default and keep the others for their specific
reasons. Mixing all three in the same module makes the surrounding
code harder to read, and that cost is real where the brand cost is
theoretical.

Next time a refactor touches a function with two string
parameters of different meanings, brand them. The diff is small.
The bug class it eliminates is the kind that hides in production
until a customer trips on it.


If this was useful

Branded types are one of the patterns The TypeScript Type System
covers in detail, alongside the broader machinery — generics,
conditional types, mapped types, infer, template literals — that
makes them and a dozen other library-grade techniques possible to
write at all. If the Brand<T, Tag> helper above made sense and
you want to know what the same intersection trick looks like
applied to type-level parsers, DSL embeddings, and the kind of
encoding ts-pattern and Zod use under the hood, that is the book.

If you are coming from JVM languages where nominal typing is the
default, Kotlin and Java to TypeScript makes the bridge into
TypeScript's structural model. If you are coming from PHP 8+,
PHP to TypeScript covers the same ground from the other side.
TypeScript in Production covers the build, monorepo, and
dual-publish concerns that the type system itself does not touch.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

The TypeScript Library — the 5-book collection

Top comments (0)