Opaque types in TypeScript are a way to create distinct types that are structurally compatible but not interchangeable, preventing accidental mixing of values that look the same but represent different things.

Let’s see this in action. Imagine we’re building a system that deals with different kinds of IDs. We want a UserId and an OrderId to both be strings, but we absolutely do not want to accidentally pass a UserId where an OrderId is expected, or vice versa.

// Define a brand type for UserId
type UserId = string & { __brand: 'UserId' };

// Define a brand type for OrderId
type OrderId = string & { __brand: 'OrderId' };

// Function to create a UserId
function createUserId(id: string): UserId {
  return id as UserId;
}

// Function to create an OrderId
function createOrderId(id: string): OrderId {
  return id as OrderId;
}

// A function that *only* accepts a UserId
function getUserName(userId: UserId): string {
  // In a real app, this would fetch user data based on userId
  console.log(`Fetching user for ID: ${userId}`);
  return `User_${userId.substring(0, 4)}`;
}

// A function that *only* accepts an OrderId
function processOrder(orderId: OrderId): void {
  // In a real app, this would process the order
  console.log(`Processing order with ID: ${orderId}`);
}

const myUserId = createUserId("user-12345");
const myOrderId = createOrderId("order-abcde");

getUserName(myUserId); // Works fine
processOrder(myOrderId); // Works fine

// This would be a type error, which is what we want!
// getUserName(myOrderId); // Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
// processOrder(myUserId); // Error: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.

// Even if they are structurally the same (both strings)
const potentiallyMixedId: string = "some-string-id";
// getUserName(potentiallyMixedId as UserId); // This cast is dangerous because `potentiallyMixedId` isn't a *real* UserId.

The core problem this solves is "structural typing gone wild." In TypeScript, if two types have the same structure, they are considered compatible. Both UserId and OrderId as defined above are structurally just string. This means a plain string could be assigned to a UserId variable, or a UserId could be passed to a function expecting a plain string, and vice-versa. This is fine for simple cases, but for distinct concepts like user IDs and order IDs, it’s a recipe for bugs. We want to say, "this is a string, but it’s specifically a UserId, and nothing else."

The magic happens with a technique called "branded types" or "opaque types" using intersection types. When we declare type UserId = string & { __brand: 'UserId' };, we’re saying that UserId is both a string (for structural compatibility, allowing us to create it from a string) and it has a unique, non-existent property __brand with the specific literal type 'UserId'. Because no other type will have exactly this combination of being a string and having __brand: 'UserId', UserId becomes distinct from OrderId (which has __brand: 'OrderId') and also distinct from a plain string.

The createUserId and createOrderId functions are crucial. They act as controlled entry points. Inside these functions, we use a type assertion (as UserId) to tell TypeScript, "I know this string is actually a UserId." This is safe because we’re creating the UserId from a string that should be a valid ID. Once created, these branded types can only be assigned to variables or passed to functions that explicitly expect that specific branded type.

The __brand property is a convention. You could name it anything, like __type or _uniqueId, as long as it’s a property name that is unlikely to be used elsewhere. The key is that the literal type of the property’s value is unique to each branded type. This makes the intersection type string & { __brand: 'UserId' } unique.

The most surprising part about this pattern is that the __brand property itself never actually exists on the runtime value. It’s purely a compile-time construct. When you log myUserId to the console, you’ll just see "user-12345", not { __brand: 'UserId', ... }. TypeScript uses the brand information during static analysis to enforce type safety but erases it during compilation to JavaScript. This means you get strong compile-time guarantees without any runtime overhead.

The next step is often realizing that you might want to perform operations that are valid for the base type (e.g., string operations) but still want to preserve the brand.

Want structured learning?

Take the full Typescript course →