Skip to Content
Course content

61: Branded and Nominal Types in TypeScript

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I want to show you a bug that has caused some genuinely terrifying production outages in my past. It’s the kind of bug that passes every single TypeScript check, passes your CI/CD pipeline, and only blows up when a user accidentally deletes the wrong record from your database.

type UserId = string;
type OrderId = string;

function cancelOrder(orderId: OrderId) {
  console.log(`Cancelling order ${orderId}...`);
  // API call to cancel order
}

function archiveUser(userId: UserId) {
  console.log(`Archiving user ${userId}...`);
  // API call to archive user
}

const currentUserId: UserId = "user_abc123";
const currentOrderId: OrderId = "order_xyz789";

// Oops! I swapped the arguments.
cancelOrder(currentUserId); 
archiveUser(currentOrderId); 

The structural typing trap

If you look at the code above, you'll notice something frustrating: TypeScript doesn't complain. Not a single red squiggly line. Why? Because TypeScript uses structural typing.

To TypeScript, UserId and OrderId aren't actually different types; they are just aliases for string. Since a string is a string, TypeScript sees no problem with passing a User ID into a function that expects an Order ID. In a small project, this is fine. In a codebase with 50 different types of IDs (Product IDs, Session IDs, Transaction IDs), it's a landmine.

If you're coming from Java or C#, you're used to nominal typing, where two classes with the exact same fields are still considered different types because they have different names. TypeScript doesn't do that by default, but we can fake it using a technique called "Branding."

Creating "Branded" Types

The trick is to tell TypeScript that a string isn't just a string—it's a string that also has a special, invisible property that only exists for the compiler. We call this a "brand."

type Brand<K, T> = T & { __brand: K };

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

function cancelOrder(orderId: OrderId) {
  console.log(`Cancelling order ${orderId}...`);
}

const currentUserId = "user_abc123" as UserId;
const currentOrderId = "order_xyz789" as OrderId;

// Now this throws a compiler error!
// Type 'UserId' is not assignable to type 'OrderId'.
cancelOrder(currentUserId); 

Here is what's happening: we've created an intersection type. UserId is now "a string AND an object with a property called __brand that equals 'UserId'". Since OrderId has a brand of 'OrderId', they are no longer compatible. I personally love this pattern because it pushes the "danger" to the edges of my application.

Handling the "Type Casting" trade-off

You probably noticed I used as UserId. This is the one "ugly" part of branded types. Since the __brand property doesn't actually exist at runtime (it's a phantom), you can't just assign a raw string to a branded type. You have to cast it.

I usually handle this by creating "Type Guards" or "Constructor" functions. Instead of scattering as casts throughout my business logic, I wrap the creation of these types in a single place:

function createUserId(id: string): UserId {
  return id as UserId;
}

function createOrderId(id: string): OrderId {
  return id as OrderId;
}

// Now the rest of my app is type-safe
const user = createUserId("user_123");
const order = createOrderId("order_456");

By doing this, you've essentially created a "nominal" system. You've told TypeScript: "I don't care if these are both strings underneath; treat them as completely different entities." It adds a tiny bit of boilerplate, but it saves you from the nightmare of accidentally deleting a user when you meant to cancel an order.




📋 Practical Task

Exercise: Implementing a Type-Safe Currency System

You are building a financial application that handles multiple currencies (USD, EUR, and GBP). Currently, the app uses number for everything, which has led to bugs where developers are adding USD amounts to EUR amounts without converting them first.

Your task:

  • Create a Brand utility type.
  • Define three branded types: USD, EUR, and GBP (all based on number).
  • Create a function calculateTotal(amount1: USD, amount2: USD): USD that adds two USD amounts together.
  • Create a "constructor" function for each currency (e.g., toUSD(val: number): USD).
  • Verify that attempting to pass a EUR value into calculateTotal causes a TypeScript compilation error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.