The Practical Developer

TypeScript Branded Types: Stop Passing the Wrong ID at Compile Time

TypeScript will happily let you pass a UserId where you meant OrderId, and the bug slips into production. Branded types add nominal typing with zero runtime cost, catching mismatched IDs, raw strings, and domain violations before they hit your database.

A close-up of a keyboard with scattered keys, representing the silent type errors that branded types prevent

A user’s payment was charged to someone else’s order. Not a SQL injection. Not a race condition. The developer called findOrder(userId) instead of findOrder(orderId), and TypeScript said nothing because both arguments were string.

This bug is not rare. It happens in every codebase that grows beyond a handful of files. TypeScript’s structural type system treats any string as compatible with any other string. If your domain has a UserId, an OrderId, and a ProductId and all three are plain strings, the compiler cannot distinguish between them. A function that expects an OrderId will happily accept a UserId, and the mistake surfaces at runtime as a 404, a wrong charge, or a corrupted record.

This post shows you how to fix that with branded types: a zero-cost abstraction that turns TypeScript’s structural type system into a nominally typed one for the identifiers that matter in your domain. You will get a compile-time error when you pass a UserId where an OrderId belongs, and you will pay exactly zero bytes at runtime.

Why plain types are not enough

Here is the problem in its simplest form:

type UserId = string;
type OrderId = string;

async function getOrder(orderId: OrderId): Promise<Order> { /* ... */ }
async function getUser(userId: UserId): Promise<User> { /* ... */ }

// This compiles. It should not.
const user = await getUser(orderId); // orderId is an OrderId, not a UserId

TypeScript sees string on both sides and calls it a day. It does not care that your domain concepts are different. It only checks the shape, and the shape of UserId and OrderId is identical: a string.

Type aliases are documentation, not enforcement. They help you read code, but they do not prevent you from writing bugs.

You could wrap every ID in a class:

class UserId { constructor(public readonly value: string) {} }
class OrderId { constructor(public readonly value: string) {} }

Now the compiler catches the mistake. But every function call becomes getUser(new UserId(someString)), every comparison requires .value, and every serialization step needs custom JSON handling. The runtime cost adds up. The ergonomic tax is high enough that most teams give up after a week.

Branded types give you the safety of nominal typing with none of the runtime overhead.

The branded type pattern

A branded type uses an intersection of the underlying type with a phantom type that only exists at compile time:

declare const UserIdBrand: unique symbol;
type UserId = string & { [UserIdBrand]: true };

declare const OrderIdBrand: unique symbol;
type OrderId = string & { [OrderIdBrand]: true };

The unique symbol declarations and the intersection with { [UserIdBrand]: true } create a type that is still a string at runtime (no wrapper object, no class instance) but is distinct at the type level. The declare const ... is never emitted in the compiled JavaScript. It disappears entirely.

Now this is a compile error:

async function getOrder(orderId: OrderId): Promise<Order> { /* ... */ }

declare const orderId: OrderId;
declare const userId: UserId;

getOrder(userId); // Error: Type 'UserId' is not assignable to type 'OrderId'.

The error message points directly to the bug: you passed a UserId where an OrderId was expected. The compiler saved you from a production incident.

Building a generic brand utility

Writing out the brand declaration for every type gets repetitive. Create a reusable utility:

declare const Brand: unique symbol;
export type Brand<T, B> = T & { [Brand]: B };

Now define your domain types with a single line each:

export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type ProductId = Brand<string, 'ProductId'>;
export type EmailAddress = Brand<string, 'EmailAddress'>;
export type PhoneNumber = Brand<string, 'PhoneNumber'>;
export type SKU = Brand<string, 'SKU'>;

The second type parameter ('UserId') is the brand name. It does not need to match anything. It just needs to be unique so the types are structurally distinct.

You can brand any primitive, not just strings:

export type UserAge = Brand<number, 'UserAge'>;
export type OrderTotal = Brand<number, 'OrderTotal'>;
export type ProductRating = Brand<number, 'ProductRating'>;
export type PositiveInteger = Brand<number, 'PositiveInteger'>;

A function that expects OrderTotal rejects UserAge at compile time, even though both are numbers.

The guardrail pattern: brand at boundaries, unbox internally

The most important rule of branded types: create branded values at trusted boundaries (API handlers, database queries, constructor functions) and use the raw type internally when you need to.

Here is the pattern:

// Boundary: the only place a UserId is created from user input
function userIdFromString(value: string): UserId {
  if (!/^[a-f0-9]{24}$/.test(value)) {
    throw new Error(`Invalid user ID format: ${value}`);
  }
  return value as unknown as UserId;
}

// Service layer: every function is type-safe
async function getOrdersForUser(userId: UserId): Promise<Order[]> {
  // Internally, cast to string when needed
  const result = await db.query(
    'SELECT * FROM orders WHERE user_id = $1',
    [userId as string]
  );
  return result.rows;
}

The cast value as unknown as UserId is the only unsafe operation. It lives in exactly one place per type: the constructor function. Every consumer of that type from that point on is compile-time safe. Review that one line in code review, and the rest of the codebase is guaranteed correct.

This is the inversion of most type-safety discussions. Instead of making every function validate its inputs (which nobody does consistently), you validate once at the boundary and encode the result in the type system. The compiler enforces the rest.

Real example: refactoring a payment flow

Consider a payment processing pipeline. Before branded types, it looks like this:

async function processPayment(userId: string, orderId: string, amount: number) {
  const user = await findUser(userId);
  const order = await findOrder(orderId);

  if (!user) throw new Error('User not found');
  if (!order) throw new Error('Order not found');

  return chargeCard(user.paymentToken, amount);
}

// Called from a route handler
router.post('/pay', async (req, res) => {
  await processPayment(req.body.userId, req.body.orderId, req.body.amount);
});

Every argument is string. Nothing stops a caller from swapping userId and orderId. Nothing validates that the amount is positive.

With branded types:

export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type Amount = Brand<number, 'PositiveAmount'>;

// Single trusted boundary per type
const UserId = {
  from: (s: string): UserId => s as unknown as UserId,
};
const OrderId = {
  from: (s: string): OrderId => s as unknown as OrderId,
};
const Amount = {
  from: (n: number): Amount => {
    if (n <= 0 || !Number.isFinite(n)) {
      throw new Error(`Invalid amount: ${n}`);
    }
    return n as unknown as Amount;
  },
};

async function processPayment(
  userId: UserId,
  orderId: OrderId,
  amount: Amount
) {
  const user = await findUser(userId);
  const order = await findOrder(orderId);

  if (!user) throw new Error('User not found');
  if (!order) throw new Error('Order not found');

  return chargeCard(user.paymentToken, amount as number);
}

// Route handler: validate and convert at the boundary
router.post('/pay', async (req, res) => {
  try {
    const userId = UserId.from(req.body.userId);
    const orderId = OrderId.from(req.body.orderId);
    const amount = Amount.from(Number(req.body.amount));

    await processPayment(userId, orderId, amount);
    res.json({ status: 'ok' });
  } catch (err) {
    res.status(400).json({ error: (err as Error).message });
  }
});

Now any call to processPayment where arguments are swapped produces a compile-time error. The validation logic for each type is centralized in one place. The route handler catches formatting errors early and returns a clean 400. The domain logic never needs to re-validate.

Branded IDs in database queries

Real applications query by ID constantly. The pattern above works well for a single ID, but what about finding a user by ID?

// Repository layer
async function findUserById(id: UserId): Promise<User | null> {
  const row = await pool.query(
    'SELECT * FROM users WHERE id = $1',
    [id as string]  // safe cast: we control the source
  );
  return row.rows[0] ?? null;
}

// Service layer
async function getUserProfile(userId: UserId): Promise<UserProfile> {
  const user = await findUserById(userId);
  if (!user) throw new NotFoundError('User', userId);
  return { id: userId, name: user.name, email: user.email };
}

The pattern stays consistent: the repository layer does the as string cast internally, and the service layer never sees a raw string. If a developer tries to call findUserById(someOrderId), TypeScript stops the build.

Common objections and why they are wrong

“It adds boilerplate.” One type definition and one constructor function per domain concept. That is less boilerplate than the runtime validation and the debugging time you spend hunting mismatched-ID bugs.

“The as unknown as T cast is unsafe.” It is the least unsafe cast in the codebase because it is isolated to a single constructor function per type. Any bug in that cast is caught by the tests for that one function. Compare that to twenty scattered as string casts that each might be wrong in a different way.

“TypeScript has opaque types proposals.” The TC39 proposal for opaque types has been in stage 0 for years. Branded types work today in TypeScript 4.x and later. Do not wait for a standard that may never ship. Use what compiles now.

“Just use classes.” Classes add runtime cost. Every new UserId(s) allocates an object. Every function call that accepts a UserId has to unwrap .value. Branded types are structurally identical to the underlying type at runtime. typeof userId returns "string". JSON.stringify({id: userId}) produces {"id":"abc"}. No custom serialization. No extra allocations. Zero bytes.

“I can just be careful.” No, you cannot. Human attention is the most expensive and unreliable resource in engineering. Let the type system enforce what you cannot trust yourself to remember at 3pm on a Friday.

When to use branded types (and when not to)

Use branded types when:

  • Your function accepts a primitive and the meaning matters (UserId, OrderId, Email)
  • You have two or more domain concepts that share the same underlying type
  • You want to prevent accidental swapping of arguments
  • You want to encode validation guarantees in the type system
  • The type crosses module boundaries (the further it travels, the more value you get)

Do not use branded types when:

  • The type is purely internal to one function and never passed around
  • You already have a class or a dedicated type (like a URL object) that carries the semantics
  • The primitive value has no semantic distinction from other values of the same type (e.g., a measurement in millimeters vs centimeters, which should use a dimensioned type instead)

Testing the boundary constructors

The constructor functions are the most important code to test because they are the single trusted entry point for each type:

describe('UserId', () => {
  it('creates a valid UserId from a hex string', () => {
    const id = UserId.from('507f1f77bcf86cd799439011');
    expect(typeof id).toBe('string');
  });

  it('rejects invalid formats', () => {
    expect(() => UserId.from('not-a-hex')).toThrow('Invalid user ID format');
    expect(() => UserId.from('')).toThrow('Invalid user ID format');
    expect(() => UserId.from('   ')).toThrow('Invalid user ID format');
  });
});

A rejection in the constructor means the error manifests as a clear 400 at the API boundary, not a mysterious 500 deep in a service function that forgot to validate.

Putting it all together: a complete pattern

Here is the full pattern you can drop into your project today:

// brand.ts -- utility
declare const Brand: unique symbol;
export type Brand<T, B> = T & { [Brand]: B };

// brand-factory.ts -- reusable factory
export function createBrandedType<T extends string | number, Name extends string>(
  name: string,
  validate?: (value: T) => boolean
) {
  const from = (value: T): Brand<T, Name> => {
    if (validate && !validate(value)) {
      throw new Error(`Invalid ${name}: ${value}`);
    }
    return value as unknown as Brand<T, Name>;
  };
  return { from };
}

// domains.ts -- your domain types
export const UserId = createBrandedType<string, 'UserId'>('UserId', (s) =>
  /^[a-f0-9]{24}$/i.test(s)
);
export const OrderId = createBrandedType<string, 'OrderId'>('OrderId', (s) =>
  /^ord_[a-z0-9]{20}$/i.test(s)
);
export const SKU = createBrandedType<string, 'SKU'>('SKU', (s) =>
  /^[A-Z]{2,4}-\d{4,8}$/.test(s)
);

export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type SKU = Brand<string, 'SKU'>;

Now UserId and OrderId are distinct types that cannot be confused at compile time. The factory handles validation and the unsafe cast in one place. Every other file in your codebase is type-safe.

The one-line habit: define a branded type for every string or number ID that crosses a function boundary. Add the factory call, export the type, and use it in your function signatures. TypeScript catches the rest.


A note from Yojji

Type safety at the domain level is one of those investments that feels like overhead on day one and saves your weekend on day ninety. It is the same engineering discipline that Yojji brings to full-cycle product development: build the guardrails into the system so the team can move fast without breaking things. Their senior engineers work across the JavaScript ecosystem (TypeScript, Node.js, React) and cloud-native infrastructure, delivering applications that are both productive to build and safe to run.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams cover the full product lifecycle from discovery through DevOps, with a focus on quality, speed, and long-term maintainability.