Skip to Content
Brand a userId So It Won't Accept an orderId
Advanced
0 likes
Tutorials

Brand a userId So It Won't Accept an orderId

Add a brand to two string ids and a parse function that creates them. After this, passing an orderId where a userId is expected is an error in the editor.

Skip ahead: If you already know the brand pattern and only want the @ts-expect-error test for it, skip to "Step 4: prove it with a test", then continue.

A function takes a userId and an orderId, both strings. Swap them and the compiler is silent. This is the recipe for making that a compile error, in four steps and about twenty lines. The lesson in the course explains why it works. This page is only the recipe.

Step 1: define one Brand type

declare const brand: unique symbol;

export type Brand<T, Name extends string> = T & { readonly [brand]: Name };

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

declare const brand: unique symbol makes a key no other code can write by accident, and declare means nothing is emitted for it. The type is an intersection: still a string, plus a tag that exists only for the compiler.

Step 2: one place that creates each id

import type { OrderId, UserId } from "./brand.js";

export function toUserId(raw: string): UserId {
  if (!/^u_\d+$/.test(raw)) {
    throw new Error(`Not a user id: ${raw}`);
  }
  return raw as UserId;
}

export function toOrderId(raw: string): OrderId {
  if (!/^o_\d+$/.test(raw)) {
    throw new Error(`Not an order id: ${raw}`);
  }
  return raw as OrderId;
}

These two functions are the only places that use as. Each one checks first. Put them where data comes in: the request handler, the database layer, the config loader.

Step 3: use the types in a signature

import type { OrderId, UserId } from "./brand.js";
import { toOrderId, toUserId } from "./ids.js";

function shipOrder(userId: UserId, orderId: OrderId): string {
  return `ship ${orderId} for ${userId}`;
}

const user = toUserId("u_100");
const order = toOrderId("o_555");

console.log(shipOrder(user, order));
console.log(shipOrder(order, user));
console.log(shipOrder("u_100", "o_555"));
ship.ts(12,23): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly [brand]: "UserId"; }'.
    Types of property '[brand]' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.
ship.ts(13,23): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly [brand]: "UserId"; }'.

The first call is fine. The swapped call fails, and so does passing plain strings, which is the point: a string has to go through the checking function to become an id. The message tells you which brand arrived and which was expected.

Run the good call to see that nothing changed at run time.

import type { OrderId, UserId } from "./brand.js";
import { toOrderId, toUserId } from "./ids.js";

function shipOrder(userId: UserId, orderId: OrderId): string {
  return `ship ${orderId} for ${userId}`;
}

console.log(shipOrder(toUserId("u_100"), toOrderId("o_555")));
console.log(typeof toUserId("u_7"));
ship o_555 for u_100
string

typeof says string. The brand is gone after compilation, so there is no cost at run time and nothing to strip before you send the id as JSON.

Step 4: prove it with a test

A brand that someone weakens later fails silently. Pin the behaviour with @ts-expect-error, which reports an error if the next line does not fail.

import type { OrderId, UserId } from "./brand.js";
import { toUserId } from "./ids.js";

declare function needsUser(id: UserId): void;
declare const order: OrderId;

needsUser(toUserId("u_1"));

// @ts-expect-error an order id is not a user id
needsUser(order);

// @ts-expect-error a plain string is not a user id
needsUser("u_1");

Run tsc --noEmit on this file in CI. If somebody changes Brand so the brands stop being distinct, the two lines stop failing, and @ts-expect-error itself becomes an error: "Unused '@ts-expect-error' directive".

Where the brand helps most

Use it as a key too. A Map keyed by UserId refuses lookups by an order id.

import type { OrderId, UserId } from "./brand.js";

declare const names: Map<UserId, string>;
declare const order: OrderId;

names.get(order);
map_keys.ts(6,11): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly [brand]: "UserId"; }'.
    Types of property '[brand]' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.

⚠ Warning: Do not add a cast helper like const id = <T>(x: string) => x as T. It makes every brand one function call away from being decoration. The parse functions above are the only casts, and each one has a test in front of it.

Common mistakes

  • Branding with the same name twice. "Id" for both types makes them identical.
  • Casting in tests and fixtures with as UserId. Give tests a helper that calls toUserId.
  • Expecting userId + "x" to stay branded. It returns a plain string.

Related course: TypeScript Advanced.

About

Add a brand to two string ids and a parse function that creates them. After this, passing an orderId where a userId is expected is an error in the editor.

Responsible
Mary Q
Last Update
10/09/2026
Reading Time
4 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.