Ch. 2 · TypeScript

TypeScript Branded IDs and Domain Safety

Why UserId and OrderId can both be strings yet must not be swapped, and how a branded type plus one parsing function makes TypeScript catch it.

~6 min readadvancedupdated Oct 7, 2026

Most backends pass around a handful of identifiers that are all strings underneath: user IDs, order IDs, tenant IDs, invoice numbers. TypeScript’s type system is structural, so two types with the same shape are interchangeable. For identifiers that’s a problem, because the shape is “a string” and every ID has it. Branding is the standard trick for getting nominal behaviour back for just those types, and interviewers like it because it tests whether you understand what structural typing actually allows.

The bug a type alias doesn’t catch

Here’s the version most codebases start with:

type UserId = string;
type OrderId = string;

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

const user: UserId = "u_42";
const order: OrderId = "o_9001";
cancelOrder(order, user); // compiles, silently wrong
TypeScript

Run tsc --strict on that and it exits cleanly. A type alias doesn’t create a new type; it gives an existing type a second name. As far as the checker is concerned, cancelOrder takes two strings, and you passed two strings.

The bug is real and it’s the kind that survives code review. A function with two or three string parameters of the same “kind” is easy to call in the wrong order, especially after a refactor that reorders parameters. Tests often miss it too, because fixtures tend to reuse the same value for several fields.

Building a brand with a unique symbol

A brand is an intersection of the primitive with a property that no ordinary string has. The cleanest version uses a unique symbol declared only at the type level:

declare const userIdBrand: unique symbol;
declare const orderIdBrand: unique symbol;

export type UserId = string & { readonly [userIdBrand]: true };
export type OrderId = string & { readonly [orderIdBrand]: true };

export function parseUserId(raw: string): UserId {
  if (!/^u_[0-9]+$/.test(raw)) {
    throw new Error(`Invalid user id: ${raw}`);
  }
  return raw as UserId;
}

export function parseOrderId(raw: string): OrderId {
  if (!/^o_[0-9]+$/.test(raw)) {
    throw new Error(`Invalid order id: ${raw}`);
  }
  return raw as OrderId;
}

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

const user = parseUserId("u_42");
const order = parseOrderId("o_9001");

cancelOrder(user, order);        // ok
cancelOrder(order, user);        // error
cancelOrder("u_42", order);      // error: plain string has no brand
const len: number = user.length; // brands keep string behaviour
const key = `user:${user}`;      // and template literals still work
TypeScript

The two bad calls now fail. Here’s what TypeScript 7 actually prints for them:

brand2.ts(27,13): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly [userIdBrand]: true; }'.
    Property '[userIdBrand]' is missing in type 'String & { readonly [orderIdBrand]: true; }' but required in type '{ readonly [userIdBrand]: true; }'.
brand2.ts(28,13): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly [userIdBrand]: true; }'.
Text

A few details are worth explaining because interviewers probe them.

declare const userIdBrand: unique symbol emits no JavaScript at all. The symbol exists only for the checker. Because each unique symbol declaration is its own distinct type, two brands built this way can never collide, even if two libraries both pick the name “brand”.

There’s nothing at runtime. A UserId is a plain string in memory, in JSON, in your database driver and in your logs. user.length and template literals work because the intersection still contains string. That’s the main selling point over wrapping IDs in a class: zero allocation, zero serialisation changes.

The direction of compatibility matters. A UserId is assignable to string, so you can hand it to any function that just wants text. A string is not assignable to UserId. That one-way door is what forces values through your parser.

Where the cast lives

The as UserId inside parseUserId is the only place the brand should be created. That’s the whole design. If casts are scattered through the codebase, the brand stops meaning anything, because any string can be asserted into any brand:

const forged = "anything" as UserId; // compiles
TypeScript

So the rule is: one constructor per branded type, sitting next to the validation that justifies it. Call it at the edges where strings enter the system, such as route parameters, message payloads and database rows, and pass branded values everywhere inside. A lint rule that bans as UserId outside the module that owns the type is a reasonable way to enforce it on a larger team.

What the parser checks depends on the ID. A regex for format, a length limit, a UUID check. Keep it about shape. Existence in the database is a different question that changes over time, so it doesn’t belong in a type.

Mistakes that weaken a brand

String-literal brands. You’ll often see a string label used as the brand. That works, and the error message is quite readable:

type LabelUserId = string & { __brand: "UserId" };
type LabelOrderId = string & { __brand: "OrderId" };
declare const someUser: LabelUserId;
const asOrder: LabelOrderId = someUser;
TypeScript
brand3.ts(4,7): error TS2322: Type 'LabelUserId' is not assignable to type 'LabelOrderId'.
  Type 'LabelUserId' is not assignable to type '{ __brand: "OrderId"; }'.
    Types of property '__brand' are incompatible.
      Type '"UserId"' is not assignable to type '"OrderId"'.
Text

The weakness is that two modules can pick the same label by accident, and the __brand property shows up in autocomplete as if it existed at runtime. The unique-symbol version avoids both.

Optional brands. If you write { __brand?: "UserId" }, a plain string becomes assignable again, because an optional property that’s missing satisfies the type. const w: string & { __brand?: "UserId" } = "plain" compiles without complaint. The brand property must be required.

Casting at deserialisation. JSON.parse(body) as { id: UserId } compiles and skips the parser entirely. The type now claims validation happened when it didn’t. Parse each field with the real constructor, or use a schema library that runs the check and applies the brand in one step.

Treating the brand as permission. This is the one interviewers most want to hear you raise. A UserId that passed parseUserId is well-formed. It is not proof that the current request may act on that user. Authorisation still happens where the operation is performed, against the session, every time.

Branded keys in collections

Brands carry into generic containers, which is where they pay off quietly:

const lastSeen = new Map<UserId, number>();
lastSeen.get("u_1"); // error
TypeScript
brand4.ts(4,14): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly [userIdBrand]: true; }'.
Text

The same applies to function return types. If findOrders(userId: UserId) returns OrderId[], a caller can’t accidentally feed those back in as user IDs. Over a large service this removes a whole category of argument-order bugs without any runtime cost.

Brands work for numbers too. type Cents = number & { readonly [centsBrand]: true } stops a dollar amount from flowing into a function that expects cents. Arithmetic on two Cents values returns number, though, so you’ll need a small helper such as addCents(a, b) that re-applies the brand. That friction is the honest cost of the pattern, and it’s worth mentioning if asked about trade-offs.

Interview practice

You join a team whose payments service has functions like refund(customerId: string, paymentId: string, amount: number). Last quarter a refund went to the wrong record because two arguments were swapped. How would you use TypeScript to prevent a repeat, and what wouldn’t it cover?

Answer

I’d introduce branded types for CustomerId, PaymentId and Cents, each built with a unique symbol property so they can’t collide. Each gets exactly one constructor that validates the raw value and then casts, and those constructors are called at the boundaries: the HTTP handler, the queue consumer, and the repository layer when rows come back from the database. The refund signature changes to refund(customerId: CustomerId, paymentId: PaymentId, amount: Cents). After that, swapping the first two arguments is a compile error, and passing a raw string is a compile error.

I’d add a lint rule restricting as CustomerId and friends to the module that owns them, so the brand can’t be forged casually.

What it doesn’t cover: runtime data that bypasses the parsers through a cast, and authorisation. A valid PaymentId doesn’t mean the customer owns that payment, so the service still checks ownership before issuing the refund. I’d also keep the existing tests that hit the refund path with distinct customer and payment fixtures, since types only guard code that’s compiled with them.

Further reading

More in TypeScript

read ✓TypeScript · mid

TypeScript Enums vs Union Types

What TypeScript enums compile to, why string enums reject plain strings from JSON, and when an as const object beats both options.

~6 min readread →
esc