Branded Types

Telling Same-shaped Types Apart

TypeScript is structural: UserId and OrderId declared as string are interchangeable, so passing one for the other is not caught. A brand adds a fake property that exists only in the type system, making them distinct (nominal typing).

Example

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Paise = Brand<number, "Paise">;

const UserId = (s: string) => s as UserId;
const OrderId = (s: string) => s as OrderId;
function toPaise(rupees: number): Paise {
  if (!Number.isFinite(rupees) || rupees < 0) throw new Error("Invalid amount");
  return Math.round(rupees * 100) as Paise;
}

function refund(orderId: OrderId, amount: Paise): string {
  return `Refund ${amount} paise for ${orderId}`;
}

const order = OrderId("OD-1001");
const user = UserId("U-77");
console.log(refund(order, toPaise(499.5)));
console.log(user.toUpperCase()); // still a string at runtime

Output

Refund 49950 paise for OD-1001
U-77

Mixing Up IDs

Example

Passing a UserId as an OrderId, or a plain number as Paise, is now an error (TS2345).

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Paise = Brand<number, "Paise">;

declare function refund(orderId: OrderId, amount: Paise): string;
declare const userId: UserId;

refund(userId, 49950);

Compiler output (tsc)

Line 10: error TS2345: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
  Type 'UserId' is not assignable to type '{ readonly [brand]: "OrderId"; }'.
    Types of property '[brand]' are incompatible.
      Type '"UserId"' is not assignable to type '"OrderId"'.

Tip: Brand money values (paise vs rupees), IDs, validated emails and sanitized HTML. Create branded values only in one validating function.