Last quarter, we shipped a bug that refunded the wrong customer. A processRefund function received a user ID where it expected an order ID. The strings looked identical, the tests passed, and TypeScript raised zero objections.
Both identifiers were typed as string. TypeScript sees no difference between them. If you’ve ever passed a userId into a parameter that wants an orderId and watched the compiler shrug, you’ve hit the same wall.
Type aliases don’t fix this. Neither does naming your variables carefully. The compiler is doing exactly what you told it to do, which is the problem.
Why type UserId = string is a lie
TypeScript uses structural typing. Two types are compatible if their structures match. When you write type UserId = string, you haven’t created a new type. You’ve created an alias. At compile time, UserId and string are identical. So are UserId and OrderId.
type UserId = string;
type OrderId = string;
function fetchOrder(id: OrderId) {
// ...
}
const userId: UserId = "usr_0192";
fetchOrder(userId); // Compiles. Oops.
The compiler strips type aliases during type checking. The name UserId is for humans. The type checker ignores it. This is usually a feature. It lets you swap implementations without ceremony. But for identifiers that should never be interchangeable, it’s a footgun.
Branded types make identical structures incompatible
The fix is a branded type, sometimes called an opaque type or newtype. You intersect the underlying type with a unique brand that exists only at the type level.
At runtime, the value is still just a string. At compile time, the brand makes it distinct from every other string.
Here’s the pattern using a unique symbol:
declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };
declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };
The unique symbol guarantees that no other type can accidentally share this brand. The readonly property means you can’t mutate it away. At runtime, the symbol property doesn’t exist on the actual string, so this is purely a compile-time construct.
To create a value, you need a constructor function that casts the raw string:
function UserId(value: string): UserId {
return value as UserId;
}
function OrderId(value: string): OrderId {
return value as OrderId;
}
Now the earlier bug is caught before you even run the code:
const uid = UserId("usr_0192");
fetchOrder(uid);
// ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
The error message is clear. The types are structurally different because their brands differ. The compiler won’t let you mix them up.
The boilerplate is annoying. Here’s a helper.
Writing a brand and a constructor for every ID type gets old fast. We use a small utility that generates both:
interface Brand<T> {
readonly __brand: T;
}
type Branded<T, B> = T & Brand<B>;
function makeBrand<T, B>(
_brand: B
): (value: T) => Branded<T, B> {
return (value) => value as Branded<T, B>;
}
Usage:
type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");
type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");
This keeps the declaration to two lines per identifier. You can also use the unique symbol approach in a generic helper if you prefer the stronger collision resistance. Either way, the goal is the same: make the cost of adding a new branded type low enough that you actually do it.
What about objects and numbers?
Branded types work for any primitive, not just strings. We brand numeric IDs the same way:
type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");
You can also brand objects. If you have two configurations that share the same shape but should never be swapped, brand them:
type ApiConfig = Branded<{
endpoint: string;
timeout: number;
}, "ApiConfig">;
Be careful with object branding, though. The runtime object won’t have the brand property, so JSON.stringify and spread operations behave normally. That’s usually what you want. But if you’re doing deep equality checks or passing values into libraries that inspect types at runtime, the brand won’t be there to help you. It is strictly a compile-time guard.
The trade-offs are real, but small
Branded types add friction. Every ID now needs a constructor call. You can’t inline a raw string into a function that expects a branded type. That’s the point, but it means more code.
Serialization is another gotcha. When you JSON.stringify a branded string, you get the raw string back. When you parse it later, you’ve lost the brand. You need to re-apply the constructor at system boundaries. We usually do this in API response parsers and database row mappers.
const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));
Branded types also don’t help with runtime validation. If a string is malformed, the brand won’t catch it. You still need zod, valibot, or manual validation at the edges of your system. The brand guarantees type distinctness, not data correctness.
When this is worth it, and when it’s noise
We brand identifiers that cross module boundaries: user IDs, organization IDs, trace IDs, span IDs. These are the values that travel through the most code and have the highest chance of being passed in the wrong order.
We don’t brand internal loop counters, local temp variables, or anything with a scope smaller than a function. The overhead isn’t worth it for values that never leave their birthplace.
If your codebase has a function signature with three consecutive string parameters, that’s a strong signal. Branded types turn that call site from a guessing game into something the compiler checks for you.
One practical starting point
You don’t need to brand every string in your codebase tomorrow. Pick the identifiers that have caused real bugs. Add brands for those. Watch the compiler catch a mix-up during your next refactor. That single moment, where a build error prevents a production bug, is when the extra constructor calls start to feel cheap.
Start with one module boundary. Add a UserId brand. Add an OrderId brand. Update your database layer to apply the constructors when rows come in. See how it feels. If it catches one wrong argument in code review, it paid for itself.