TypeScript branded types let you achieve nominal typing – distinguishing between types that are structurally identical but semantically different – without any runtime cost.

Let’s see this in action. Imagine we have a UserId and a ProductId. Both are just numbers, but we want to ensure we never accidentally assign a product ID to a user ID variable, and vice-versa.

type UserId = number & { __brand: "UserId" };
type ProductId = number & { __brand: "ProductId" };

function getUser(id: UserId) {
  console.log(`Fetching user with ID: ${id}`);
}

function getProduct(id: ProductId) {
  console.log(`Fetching product with ID: ${id}`);
}

// This works fine
const myUserId = 123 as UserId;
getUser(myUserId);

const myProductId = 456 as ProductId;
getProduct(myProductId);

// This will cause a TypeScript error
// getUser(myProductId); // Argument of type 'ProductId' is not assignable to parameter of type 'UserId'.

// This will also cause a TypeScript error
// const anotherUserId = 789 as ProductId;
// getUser(anotherUserId); // Argument of type 'ProductId' is not assignable to parameter of type 'UserId'.

The magic here is the intersection type: number & { __brand: "UserId" }. This tells TypeScript that UserId is a number and it has a unique, non-existent property __brand with the literal string "UserId". Because the __brand property is different for UserId and ProductId, TypeScript treats them as distinct types, even though they are both fundamentally numbers at runtime.

This solves the problem of "primitive obsession," where you use raw primitives like string or number for domain-specific values. Without branding, there’s no compile-time safety. You could pass a ZIP code string to a function expecting an email string, or a user ID number to a function expecting a product ID number. Branded types introduce this safety.

Internally, TypeScript’s type checker uses these __brand properties to enforce type distinctions. When it sees UserId and ProductId, it checks if the __brand property matches. If they don’t match, even if the underlying type (like number) is compatible, it flags a type error. Crucially, at runtime, the __brand property doesn’t exist. It’s purely a compile-time construct. This means there’s zero performance overhead. Your JavaScript output is just plain numbers.

The "as" keyword is essential for creating branded types. You’re essentially telling TypeScript, "I know this number is specifically a UserId." This is a type assertion, and like all type assertions, it bypasses some of TypeScript’s safety checks. You must be sure that the value you’re asserting is indeed a UserId in your program’s logic.

When creating a branded type, the common pattern is UnderlyingPrimitive & { __brand: "UniqueBrandName" }. The UnderlyingPrimitive can be string, number, boolean, or even another object type. The __brand property is a convention; you could technically use any unique property name, but __brand is widely adopted and understood. The string literal "UniqueBrandName" is what makes the type unique. It’s arbitrary but must be distinct for each branded type.

You can also use branded types to create more complex types, like discriminated unions, by combining them with other properties. For example, you might have a SuccessResponse and an ErrorResponse, both of which have a status property.

type SuccessStatus = "OK" & { __brand: "Success" };
type ErrorStatus = "FAIL" & { __brand: "Error" };

type ApiResponse =
  | { status: SuccessStatus; data: any }
  | { status: ErrorStatus; message: string };

function handleResponse(response: ApiResponse) {
  if (response.status === "OK") { // This comparison works because "OK" is compatible with SuccessStatus
    console.log("Success:", response.data);
  } else { // response.status must be ErrorStatus here
    console.log("Error:", response.message);
  }
}

The trick to making discriminated unions with branded types work seamlessly is that the literal string used for the brand often aligns with a value you’d naturally use in a union. In the ApiResponse example, "OK" is the value that satisfies the SuccessStatus brand. This allows for natural comparisons and type narrowing.

The most surprising aspect is how effectively this pattern leverages TypeScript’s structural typing to simulate nominal typing. While TypeScript fundamentally checks types based on their shape (structure), by adding a unique, non-existent property through an intersection, you create a distinct shape that can never be accidentally matched by another type with a different brand, regardless of their shared underlying primitive.

The next step in mastering type safety without runtime cost often involves exploring mapped types and conditional types to dynamically create or manipulate branded types based on existing structures.

Want structured learning?

Take the full Typescript course →