TypeScript Best Practices

Chapter 2 · Practice 6 of 6

Brand ids and units that share a primitive type

From Modeling data with types

Why it matters

A userId and an orderId are both strings, so TypeScript happily lets you pass one where the other is expected. The same goes for cents and dollars, or milliseconds and seconds. These bugs pass every type check and show up as wrong data in production.

A branded type adds a phantom property that exists only in the type system. A plain string no longer fits, so values must come from a function that creates the brand, usually where the value enters the program: a database row, a parsed URL or a validated request.

Use brands where mixing values would be costly and hard to spot. Do not brand every string in your app: the extra conversions add friction.

Do

  • Brand ids that travel through many layers, and amounts with units

Avoid

  • Branding everything, which turns simple code into a chain of casts

Example

TypeScript
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

const toUserId = (raw: string) => raw as UserId; // the one place a cast is allowed

declare function getOrders(user: UserId): Promise<OrderId[]>;
// getOrders('abc') is a type error; getOrders(toUserId('abc')) is fine