← Back to Blog

Branded Types Changed How I Write TypeScript

· 6 min read
typescriptpatternsarchitecturefrontend

Last year I tracked down a bug that took our team three days to find. A function was receiving a userId but treating it as an orderId. Both were strings. TypeScript was perfectly happy. The code compiled, the tests passed (we were mocking anyway), and the bug only surfaced when a customer in production got someone else’s order history.

Three days. Because two strings walked into a function and nobody asked for ID.

That incident pushed me to adopt branded types across our entire API layer. It’s a pattern I now consider essential for any TypeScript codebase that deals with multiple entity types — which is basically all of them.

The Problem With Primitive Types

Consider a typical service function:

async function transferCredits(fromUserId: string, toUserId: string, amount: number) {
  await api.post("/transfers", { fromUserId, toUserId, amount });
}

// This compiles fine. It's also completely wrong.
await transferCredits(orderId, visitorSessionId, accountBalance);

TypeScript’s structural type system treats all strings as interchangeable. A userId, an orderId, a sessionToken, and the literal string "banana" are all string. The compiler can’t help you when you swap two arguments of the same type.

You can catch this in code review if you’re lucky, or in production if you’re not.

Branded Types: Nominal Typing in a Structural World

A branded type attaches a unique “tag” to a primitive, making it incompatible with other primitives — even if the underlying runtime value is identical.

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

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

The __brand property never exists at runtime. It’s a phantom type — it only lives in the type system. But it gives TypeScript enough information to distinguish between different strings:

function transferCredits(from: UserId, to: UserId, amount: Credits) {
  /* ... */
}

const userId = "abc-123" as UserId;
const orderId = "order-456" as OrderId;

transferCredits(userId, orderId, 100 as Credits);
//                      ^^^^^^^ Error: Argument of type 'OrderId'
//                               is not assignable to parameter of type 'UserId'

That’s the entire pattern. Four lines of type definition, and an entire category of bugs becomes a compile-time error.

Don’t Just Cast — Validate at the Boundary

The as UserId cast above works, but it’s a lie if you use it carelessly. The real power comes from creating validated constructors that brand values only after checking them:

class ValidationError extends Error {
  constructor(
    public field: string,
    message: string,
  ) {
    super(message);
    this.name = "ValidationError";
  }
}

function parseUserId(raw: string): UserId {
  const trimmed = raw.trim();
  if (trimmed.length === 0) {
    throw new ValidationError("userId", "User ID cannot be empty");
  }
  if (!/^usr_[a-zA-Z0-9]{12,}$/.test(trimmed)) {
    throw new ValidationError("userId", `Invalid user ID format: ${trimmed}`);
  }
  return trimmed as UserId;
}

function parseCredits(raw: number): Credits {
  if (!Number.isFinite(raw) || raw < 0) {
    throw new ValidationError("credits", "Credits must be a non-negative number");
  }
  return Math.floor(raw) as Credits;
}

Now the as cast happens in exactly one place, guarded by validation. Everywhere else in your codebase, you work with branded types and never think about raw strings again.

This is the pattern I enforce in every project: raw data enters at the boundary, gets validated and branded, and flows through the system as typed values. If a function accepts UserId, you know it’s already been validated. No defensive checks scattered everywhere.

Pairing With Zod for API Responses

In practice, your API responses are the biggest boundary. Here’s how I wire branded types into Zod schemas so that every API response is validated and branded automatically:

import { z } from "zod";

const UserIdSchema = z
  .string()
  .regex(/^usr_[a-zA-Z0-9]{12,}$/)
  .transform((val) => val as UserId);

const OrderIdSchema = z
  .string()
  .regex(/^ord_[a-zA-Z0-9]{12,}$/)
  .transform((val) => val as OrderId);

const OrderSchema = z.object({
  id: OrderIdSchema,
  userId: UserIdSchema,
  total: z
    .number()
    .nonneg()
    .transform((val) => val as Credits),
  status: z.enum(["pending", "confirmed", "shipped", "delivered"]),
  createdAt: z.string().datetime(),
});

type Order = z.infer<typeof OrderSchema>;
// Order.id is OrderId, Order.userId is UserId, Order.total is Credits

The z.infer type automatically picks up the branded types from the .transform() calls. Your API client returns fully typed, fully validated, fully branded data — and downstream code can’t accidentally misuse it.

A Real API Client Using This Pattern

Here’s a simplified version of what our API client looks like after adopting this:

async function fetchOrder(id: OrderId): Promise<Order> {
  const response = await fetch(`/api/orders/${id}`);
  const data = await response.json();
  return OrderSchema.parse(data);
}

async function reassignOrder(orderId: OrderId, newOwnerId: UserId): Promise<void> {
  await fetch(`/api/orders/${orderId}/reassign`, {
    method: "POST",
    body: JSON.stringify({ userId: newOwnerId }),
  });
}

Try calling reassignOrder with the arguments swapped. TypeScript won’t let you. Try passing an unvalidated string from useParams() directly into fetchOrder. TypeScript won’t let you. You’re forced to go through a parser first, which is exactly what you want.

Where This Pattern Shines

After rolling this out across two production codebases, here’s where I’ve seen the most impact:

Multi-entity dashboards. Admin panels that display users, orders, products, and transactions on the same page are a minefield for ID confusion. Branded types make it structurally impossible to pass a ProductId where an OrderId is expected.

Event tracking. Analytics calls that take multiple ID parameters (trackEvent("purchase", userId, productId, sessionId)) are notorious for argument-order bugs. Branding each one turns silent data corruption into a compile error.

Database layers. If you’re using Prisma or Drizzle, you can brand the IDs coming out of your ORM so that your entire data access layer is type-safe from database to UI.

The Tradeoff

I won’t pretend this is free. Branded types add friction. You can’t just pass params.id into a function anymore — you have to parse it first. New team members will ask why they can’t just use a string. You’ll write more code at the boundaries.

That friction is the point. Every time the compiler stops you, it’s a potential bug that would have reached production. After living with this pattern for a year, I can’t imagine going back. The three-day debugging session that started all this? It would have been a red squiggly line.

The best TypeScript patterns don’t add complexity to your business logic. They move complexity to the edges — where data enters your system — and make the interior simple and safe. Branded types do exactly that.


If you’re using TypeScript without branded types, try introducing them for just your ID types first. Start small: UserId, OrderId, whatever your domain’s core entities are. Give it two weeks. I think you’ll find, like I did, that it changes how you think about type safety entirely.