Q33 of 38 · TypeScript

How do branded types work in TypeScript, and when should you use them in a test project?

TypeScriptSeniortypescriptbranded-typesnominal-typingtype-safetyadvanced-typestest-data

Short answer

Short answer: Branded types add a phantom property to a primitive type, making structurally identical primitives incompatible without losing their underlying type. Use them in test suites for typed IDs (UserId, FixtureKey), validated strings (Email, URL), and to ensure fixtures or API helpers receive the semantically correct argument.

Detail

A branded type is a primitive type intersected with an object type containing a unique, uninstantiable property that serves as a type-level marker.

Creating a brand:

declare const _: unique symbol;
type UserId = number & { readonly [_]: "UserId" };

Because unique symbol is always distinct, UserId and OrderId (with a different brand) are structurally incompatible even though both extend number.

Making a value: You create a branded value through a constructor function that uses as UserId. This is a controlled narrowing point — only code that calls makeUserId(n) produces a valid UserId.

Use cases in test suites:

  1. Typed entity IDs: UserId, OrderId, ProductId prevent accidentally swapping IDs in complex multi-entity test scenarios.
  2. Validated strings: Email and URL types that can only be created by a validation function — no raw strings allowed in API calls.
  3. Test state tokens: FixtureRef or AuthToken — ensuring fixture setup functions return the correct kind of reference.

Opaque types: A variant where you also hide the underlying primitive with a module-level type barrier — only the defining module can create or unwrap values. Achievable by controlling exports.

// EXAMPLE

// Define brands with unique symbols
declare const userBrand: unique symbol;
declare const orderBrand: unique symbol;

type UserId  = number & { readonly [userBrand]: void };
type OrderId = number & { readonly [orderBrand]: void };

// Constructor functions — the only entry points
function userId(n: number): UserId   { return n as UserId; }
function orderId(n: number): OrderId { return n as OrderId; }

// API helpers
function getUser(id: UserId): Promise<User>   { /* ... */ }
function getOrder(id: OrderId): Promise<Order> { /* ... */ }

// Test
const uid = userId(1);
const oid = orderId(42);

getUser(uid);  // ok
// getUser(oid); // Error! OrderId not assignable to UserId
// getUser(1);   // Error! plain number not assignable to UserId

// Email branded type with validation
type Email = string & { readonly _emailBrand: void };
function parseEmail(s: string): Email {
  if (!s.includes("@")) throw new Error("Invalid email");
  return s as Email;
}

// WHAT INTERVIEWERS LOOK FOR

The unique symbol brand mechanism. Constructor functions as the controlled creation point. Real-world test use cases — typed IDs and validated strings are the most practical. The opaque type concept as a bonus. This pattern shows mature type system thinking.

// COMMON PITFALL

Using a string literal type as the brand (e.g., `{ _brand: 'UserId' }`) — while it works, `unique symbol` is better because two modules can accidentally use the same string, making the brands compatible.