02 Branded types 6 chapters

Theme 01 · Type-Safety Maximalist, But Pragmatic

Branded types

Explanation

Branded types

Plain Human Explanation

Some values look identical to the computer but mean very different things to the product. A user id, team id, and Stripe customer id may all be strings, but swapping them can show the wrong data or charge the wrong account. A brand gives each value a name the type checker can remember.

Technical Explanation

A branded type is a primitive value plus a compile-time marker. It still behaves like a string or number at runtime, but TypeScript will not let you pass a TeamId where a UserId is required. Brands are usually created by small parsing or constructor functions, so raw outside values must be checked before entering trusted code.

Why It Matters

  • User impact: people are less likely to see another account’s data or have actions applied to the wrong record.
  • Product behavior: ids, money values, and external references keep their intended meaning across module boundaries.
  • Risk: plain strings and numbers are easy to swap because TypeScript sees them as compatible.
  • Decision point: use this when two values have the same runtime type but different business meaning.

The Core Move

Turn important look-alike primitives into named types at the boundary. Keep the brand small and local to the values where accidental swaps would be costly.

Small Example

Branded types: Small Example

Bad TypeScript Example

type UserId = string;
type TeamId = string;

export function addUserToTeam(userId: UserId, teamId: TeamId) {
  return { userId, teamId };
}

addUserToTeam("team_123", "user_456");
type UserId = string;
type TeamId = string;

export function addUserToTeam(
  userId: UserId,
  teamId: TeamId,
) {
  return {
    userId,
    teamId,
  };
}

addUserToTeam("team_123", "user_456");

Good TypeScript Example

type Brand<T, Name extends string> = T & {
  readonly __brand: Name;
};

type UserId = Brand<string, "UserId">;
type TeamId = Brand<string, "TeamId">;

function userId(value: string): UserId {
  if (!value.startsWith("user_")) throw new Error("Invalid user id");
  return value as UserId;
}

function teamId(value: string): TeamId {
  if (!value.startsWith("team_")) throw new Error("Invalid team id");
  return value as TeamId;
}

export function addUserToTeam(userId: UserId, teamId: TeamId) {
  return { userId, teamId };
}

addUserToTeam(userId("user_456"), teamId("team_123"));
type Brand<T, Name extends string> = T & {
  readonly __brand: Name;
};

type UserId = Brand<string, "UserId">;
type TeamId = Brand<string, "TeamId">;

function userId(value: string): UserId {
  if (!value.startsWith("user_"))
    throw new Error("Invalid user id");

  return value as UserId;
}

function teamId(value: string): TeamId {
  if (!value.startsWith("team_"))
    throw new Error("Invalid team id");

  return value as TeamId;
}

export function addUserToTeam(
  userId: UserId,
  teamId: TeamId,
) {
  return {
    userId,
    teamId,
  };
}

addUserToTeam(
  userId("user_456"),
  teamId("team_123"),
);

What Changed

  • The bad version aliases both ids to string, so the call can be reversed without a compiler error.
  • The good version gives each id a compile-time identity.
  • The constructor functions create one obvious place to validate raw strings.

Realistic Example

Branded types: Realistic Example

This example uses billing values where plain numbers and strings can be mixed up easily.

Bad TypeScript Example

type Invoice = {
  userId: string;
  stripeCustomerId: string;
  amount: number;
};

export function createInvoice(userId: string, stripeCustomerId: string, amount: number): Invoice {
  return { userId: stripeCustomerId, stripeCustomerId: userId, amount };
}
type Invoice = {
  userId: string;
  stripeCustomerId: string;
  amount: number;
};

export function createInvoice(
  userId: string,
  stripeCustomerId: string,
  amount: number,
): Invoice {
  return {
    userId: stripeCustomerId,
    stripeCustomerId: userId,
    amount,
  };
}

Good TypeScript Example

type Brand<T, Name extends string> = T & {
  readonly __brand: Name;
};

type UserId = Brand<string, "UserId">;
type StripeCustomerId = Brand<string, "StripeCustomerId">;
type Cents = Brand<number, "Cents">;

type Invoice = {
  userId: UserId;
  stripeCustomerId: StripeCustomerId;
  amountCents: Cents;
};

export function createInvoice(userId: UserId, stripeCustomerId: StripeCustomerId, amountCents: Cents): Invoice {
  return { userId, stripeCustomerId, amountCents };
}
type Brand<T, Name extends string> = T & {
  readonly __brand: Name;
};

type UserId = Brand<string, "UserId">;
type StripeCustomerId = Brand<
  string,
  "StripeCustomerId"
>;
type Cents = Brand<number, "Cents">;

type Invoice = {
  userId: UserId;
  stripeCustomerId: StripeCustomerId;
  amountCents: Cents;
};

export function createInvoice(
  userId: UserId,
  stripeCustomerId: StripeCustomerId,
  amountCents: Cents,
): Invoice {
  return {
    userId,
    stripeCustomerId,
    amountCents,
  };
}

What Changed

  • The bad version can accidentally store the Stripe id in the user id field.
  • The good version makes each look-alike value distinct before it reaches invoice creation.
  • Naming money as Cents avoids mixing dollars, cents, percentages, and raw numbers.

System Example

Branded types: System Example

At system scale, ids cross API handlers, jobs, analytics, and webhooks. Brands keep those boundaries honest.

Larger System-Level Bad TypeScript Example

type AuditEvent = {
  actorId: string;
  accountId: string;
  requestId: string;
};

type AuditStore = {
  auditEvents: {
    insert(event: AuditEvent): Promise<void>;
  };
};

export async function recordAccountChange(db: AuditStore, actorId: string, accountId: string, requestId: string) {
  await db.auditEvents.insert({
    actorId: accountId,
    accountId: requestId,
    requestId: actorId,
  });
}
type AuditEvent = {
  actorId: string;
  accountId: string;
  requestId: string;
};

type AuditStore = {
  auditEvents: {
    insert(
      event: AuditEvent,
    ): Promise<void>;
  };
};

export async function recordAccountChange(
  db: AuditStore,
  actorId: string,
  accountId: string,
  requestId: string,
) {
  await db.auditEvents.insert({
    actorId: accountId,
    accountId: requestId,
    requestId: actorId,
  });
}

Larger System-Level Good TypeScript Example

type Brand<T, Name extends string> = T & {
  readonly __brand: Name;
};

type ActorId = Brand<string, "ActorId">;
type AccountId = Brand<string, "AccountId">;
type RequestId = Brand<string, "RequestId">;

type AuditEvent = {
  actorId: ActorId;
  accountId: AccountId;
  requestId: RequestId;
};

type AuditStore = {
  auditEvents: {
    insert(event: AuditEvent): Promise<void>;
  };
};

export async function recordAccountChange(db: AuditStore, actorId: ActorId, accountId: AccountId, requestId: RequestId) {
  await db.auditEvents.insert({ actorId, accountId, requestId });
}
type Brand<T, Name extends string> = T & {
  readonly __brand: Name;
};

type ActorId = Brand<string, "ActorId">;
type AccountId = Brand<string, "AccountId">;
type RequestId = Brand<string, "RequestId">;

type AuditEvent = {
  actorId: ActorId;
  accountId: AccountId;
  requestId: RequestId;
};

type AuditStore = {
  auditEvents: {
    insert(
      event: AuditEvent,
    ): Promise<void>;
  };
};

export async function recordAccountChange(
  db: AuditStore,
  actorId: ActorId,
  accountId: AccountId,
  requestId: RequestId,
) {
  await db.auditEvents.insert({
    actorId,
    accountId,
    requestId,
  });
}

What Changed

  • The bad version relies on argument order even though every argument is a string.
  • The good version makes each id carry its role across the system boundary.
  • The audit store accepts the branded event, so downstream code cannot silently shuffle ids.

When To Use It

Branded types: When To Use It

Use This When

  • Two or more values share the same primitive type but must not be swapped.
  • The value crosses several functions or modules after it has been validated.
  • A mix-up would affect account ownership, billing, permissions, analytics, or external provider calls.

Avoid This When

  • The value is local to one tiny function and the name already makes the role obvious.
  • The team would need many casts because raw values are not parsed at the boundary yet.
  • The brand only repeats a field name and does not prevent a real mistake.

Tradeoffs

Brands are compile-time only. They do not validate data by themselves. Pair them with constructors or parsers, and keep the brand names boring and specific.

  • Parsed inputs
  • Refined values
  • Strict TypeScript settings

Practice Prompt

Branded types: Practice Prompt

Beginner Exercise

Find a function that accepts two or more strings or numbers with different meanings. Rename the parameters so the possible swap is obvious.

Intermediate Exercise

Create two branded types and constructor functions for those values. Update one caller so raw strings are parsed before the function call.

Stretch Exercise

Carry one brand through a small boundary, such as an API handler into a service function or a service function into a store method.

Reflection Question

What exact swap does the brand prevent, and where should raw values first become trusted branded values?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.