TypeScript’s structural typing means that two types are considered compatible if they have the same shape, regardless of their names. This can lead to unexpected behavior when you want to ensure that values of seemingly identical types are not accidentally interchanged.
Let’s see this in action. Imagine we have a UserId and a ProductId. Both are just numbers under the hood, but semantically, they represent completely different things.
type UserId = number;
type ProductId = number;
function greetUser(id: UserId) {
console.log(`Hello user with ID: ${id}`);
}
const userId: UserId = 123;
const productId: ProductId = 456;
greetUser(userId); // This works fine.
// But this also works, which is problematic!
greetUser(productId);
In the example above, greetUser expects a UserId, but we can pass a ProductId because both are numbers. TypeScript sees them as structurally identical and allows it. This is a silent bug waiting to happen.
The problem arises because TypeScript’s default type checking is structural. It compares the members of types. If two types have the same members, they are compatible. This is great for interfaces and classes where you want flexibility, but it’s a pain when you want to enforce distinctness based on meaning rather than structure.
The solution is to introduce nominal typing using branded types. A branded type is a type that is structurally identical to another type but has a unique, impossible-to-replicate "brand" added to it. This brand is typically a literal string property that doesn’t actually exist at runtime but serves to distinguish types at compile time.
Here’s how you create branded types for UserId and ProductId:
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<number, 'UserId'>;
type ProductId = Brand<number, 'ProductId'>;
function greetUser(id: UserId) {
console.log(`Hello user with ID: ${id}`);
}
const userId: UserId = 123 as UserId; // Type assertion needed to create
const productId: ProductId = 456 as ProductId; // Type assertion needed to create
greetUser(userId); // Works.
// greetUser(productId); // Error: Argument of type 'ProductId' is not assignable to parameter of type 'UserId'.
In this refined example, we define a generic Brand type. Brand<K, T> creates a type that is a subtype of K (in our case, number) and also has a unique property __brand with the literal type T (e.g., 'UserId' or 'ProductId'). The __brand property is purely for TypeScript’s type checking; it doesn’t add any overhead at runtime.
When you create a value of a branded type, you need to use a type assertion (as UserId or as ProductId). This tells TypeScript, "I know this number is specifically a UserId." Once created, these types are no longer structurally compatible. A UserId is a number with the brand 'UserId', and a ProductId is a number with the brand 'ProductId'. Because the __brand property types are different literals, TypeScript sees them as distinct.
The key benefit here is compile-time safety. The greetUser function now strictly enforces that it only receives a UserId. If you accidentally try to pass a ProductId, TypeScript will immediately flag it as an error before your code even runs, preventing a whole class of runtime bugs. You’re essentially adding a semantic layer of type safety on top of TypeScript’s structural system.
The __brand property itself is arbitrary. You could use __type, _tag, or any other unique property name. The crucial part is that the literal string value associated with that property ('UserId' vs. 'ProductId') is what makes the types distinct to the TypeScript compiler.
The next hurdle you’ll often face is how to handle these branded types when interacting with external libraries or APIs that don’t use them, or when you need to convert back to the underlying primitive type for certain operations.