DEV Community

Cover image for Branded Types: Stop Passing the Wrong String to the Right Function
Gabriel Anhaia
Gabriel Anhaia

Posted on

Branded Types: Stop Passing the Wrong String to the Right Function


You have a function that cancels an order. It takes an order
ID and a user ID, both strings, in that order.

function cancelOrder(
  orderId: string,
  userId: string,
): void {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Six months later somebody calls it with the arguments
swapped. The compiler says nothing. Both values are strings,
the signature wants two strings, the call type-checks. You
find out in production when a refund posts against the wrong
record and a customer emails support.

That bug exists because TypeScript is structural. A type is
whatever shape it has, and a string is a string no matter
what it means in your domain. The compiler cannot tell a user
ID from an order ID from a postal code, because at the type
level they are the same thing.

Branded types close that gap. They make two values that share
a runtime representation distinct at compile time, and they do
it without adding a single byte to your bundle.

What a brand is

A brand is a fake property you attach to a type. It never
exists at runtime. It exists only so the compiler treats two
otherwise-identical types as incompatible.

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

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
Enter fullscreen mode Exit fullscreen mode

UserId is a string intersected with an object carrying a
phantom __brand field. At runtime the value is still a plain
string. At compile time it is a string that also claims to
have a __brand of "UserId", and no real string has that.

So you cannot assign a raw string to a UserId, and you
cannot pass a UserId where an OrderId is wanted.

const raw = "u_123";
const id: UserId = raw;
// Error: 'string' is not assignable to 'UserId'.

declare const orderId: OrderId;
const u: UserId = orderId;
// Error: 'OrderId' is not assignable to 'UserId'.
Enter fullscreen mode Exit fullscreen mode

The brand key can be a string literal, a symbol, or a unique
enum value. A string literal is the most readable and the one
most teams settle on.

Getting a value into the type: smart constructors

You cannot assign a raw string to UserId, which raises an
obvious question. How does any value ever become one?

The answer is a smart constructor: a function that takes the
raw value, checks it, and returns the branded type. The only
place a brand gets applied is inside that function, behind a
validation gate. Everywhere else the type is opaque.

function toUserId(raw: string): UserId {
  if (!raw.startsWith("u_")) {
    throw new Error(`Bad user id: ${raw}`);
  }
  return raw as UserId;
}

function toOrderId(raw: string): OrderId {
  if (!raw.startsWith("o_")) {
    throw new Error(`Bad order id: ${raw}`);
  }
  return raw as OrderId;
}
Enter fullscreen mode Exit fullscreen mode

The as UserId cast is the one place you lie to the compiler.
That is the point. The cast lives inside a function that has
already proven the value is a real user ID, so the lie is
backed by a runtime check. The rest of the codebase never
casts; it calls toUserId and receives a value it can trust.

Now the swapped-argument call from the opening fails to
compile.

function cancelOrder(
  orderId: OrderId,
  userId: UserId,
): void {
  // ...
}

const orderId = toOrderId("o_42");
const userId = toUserId("u_7");

cancelOrder(orderId, userId); // ok
cancelOrder(userId, orderId); // Error on both args
Enter fullscreen mode Exit fullscreen mode

The boundary where raw strings turn into branded values is
usually small: request parsing, a database row mapper, the
edge of a third-party SDK. Validate once at the edge, hand
branded types inward, and the interior of your app stops
passing the wrong string to the right function.

A constructor that returns errors instead of throwing

Throwing works, but a lot of codebases prefer a result type at
the boundary so callers handle bad input explicitly.

type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: string };

function parseUserId(raw: string): Result<UserId> {
  if (!/^u_[0-9a-f]{8}$/.test(raw)) {
    return { ok: false, error: "malformed user id" };
  }
  return { ok: true, value: raw as UserId };
}

const r = parseUserId(input);
if (r.ok) {
  cancelOrder(orderId, r.value); // r.value is UserId
}
Enter fullscreen mode Exit fullscreen mode

Same brand, same zero-cost runtime. The difference is the
caller cannot reach the UserId without first checking r.ok,
so an unvalidated string can never leak past the gate. Pick the
throwing form or the result form to match how the rest of your
code handles failure; the branding mechanism is identical.

Branding numbers and other primitives

Strings are the common case, but the same trick works for any
primitive that carries domain meaning. Money is the textbook
example: cents and dollars are both numbers, and mixing them
is a classic off-by-100 bug.

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

function toCents(n: number): Cents {
  if (!Number.isInteger(n)) {
    throw new Error("cents must be whole");
  }
  return n as Cents;
}

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

const price = toCents(1999);
chargeCard(price);          // ok
chargeCard(19.99);          // Error: number is not Cents
Enter fullscreen mode Exit fullscreen mode

The same pattern fits epoch milliseconds versus seconds,
latitude versus longitude, a validated email versus any
string. Anywhere two values share a representation but must not
be interchanged, a brand keeps them apart.

What it costs at runtime: nothing

This is the part worth being precise about. The brand is a
type-level construct. The __brand property is never written,
never read, never serialized. After the TypeScript compiler
erases types, a UserId is a string and a Cents is a
number.

const id = toUserId("u_abc");
console.log(typeof id);     // "string"
console.log(id === "u_abc"); // true
JSON.stringify({ id });     // {"id":"u_abc"}
Enter fullscreen mode Exit fullscreen mode

There is no wrapper object, no class, no allocation. Comparison,
serialization, and string methods all work because the value
genuinely is a string. The only overhead is whatever validation
you put inside the smart constructor, and you would be running
that check anyway. Compare that to wrapping every ID in a class
instance, which costs an allocation per value and breaks
JSON.stringify unless you write a custom serializer.

Where it pays off, and where it does not

Branding earns its keep when distinct domain values share a
primitive type and getting them confused is expensive. The
clearest wins:

  • ID types that flow through many function calls: UserId, OrderId, TenantId. These get swapped most because they look identical and travel together.
  • Money and units: Cents vs Dollars, Meters vs Feet, Milliseconds vs Seconds.
  • Validated-string types: Email, Url, Uuid — the brand records that the value passed a check, so downstream code does not re-validate or wonder whether it should.

It is not worth the ceremony when a value never leaves a small
scope, or when there is only one string type in the whole
function and nothing to confuse it with. Branding a local
variable that lives for three lines adds noise without
removing a real bug.

A reasonable rule: brand the IDs and quantities that cross
module boundaries. Leave the throwaway locals alone.

Wiring it through your domain

In practice you put the brand definition and its constructor
next to the type they describe, and you export the constructor,
not the cast.

// user-id.ts
export type UserId = Brand<string, "UserId">;

export function toUserId(raw: string): UserId {
  if (!/^u_[0-9a-f]{8}$/.test(raw)) {
    throw new Error(`invalid UserId: ${raw}`);
  }
  return raw as UserId;
}
Enter fullscreen mode Exit fullscreen mode

The rest of the codebase imports UserId and toUserId. The
as UserId cast lives in exactly one file, behind one check.
Nobody else writes a cast, so there is one place to audit when
you want to know how a UserId can come into existence.

That single property is what makes branding more than a typing
trick. A UserId in your function signature is a promise that
the value passed through validation. The compiler holds every
caller to that promise, and it holds them at build time, for
free.

If you ship enough of these, you stop thinking in raw strings.
A function that takes a string starts to look suspicious,
because most strings in a real domain mean something more
specific than "any sequence of characters."


Branded types are one corner of what the type system can do
once you stop treating it as decoration. If this pattern made
the structural-typing tradeoff click, the generics, conditional
types, and infer machinery that branding composes with are
the rest of the toolkit. The TypeScript Type System is the
book that goes there.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

  1. TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
  2. The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
  4. PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
  5. TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)