TypeScript intersection types and branded types allow you to enforce strict domain constraints. Learn to build safer, more predictable codebases today.
Last month, I spent about three days debugging a silent data corruption issue where a userId was accidentally swapped with an orderId. Both were just string types, and the compiler was perfectly happy to let me pass the wrong one into a database query. It was a classic case of primitive obsession, and it cost us a painful afternoon of manual data reconciliation.
If you’re relying on standard interfaces to hold your domain logic together, you’re likely leaving the door open for these kinds of bugs. To fix this, I started moving toward a pattern that combines TypeScript intersection types with nominal branded interfaces. This approach forces your code to acknowledge the difference between a "raw" string and a "validated" domain entity.
In a typical JavaScript-heavy codebase, we often define our entities like this:
TYPESCRIPTinterface User { id: string; email: string; }
The problem is that any string can be a userId. If you have a function deleteUser(id: string), you could accidentally pass it an email string, and TypeScript wouldn't blink. We need a way to make these strings "nominal"—meaning they are only compatible with other strings of the exact same semantic type.
We’ve covered how TypeScript Branded Types: Enforcing Domain Integrity at Compile-Time can solve this by creating opaque types. But as our domain grows, simple branding isn't always enough to handle complex, multi-layered constraints.
When you need to ensure an object isn't just an ID, but a validated ID that satisfies specific business rules, you can use intersection types. An intersection type allows you to combine your branded type with a set of validation constraints.
Consider this implementation:
TYPESCRIPTtype Brand<K, T> = T & { __brand: K }; type Email = Brand<CE9178">'Email', string>; type Validated = Brand<CE9178">'Validated', boolean>; // An intersection type that represents a validated email type ValidatedEmail = Email & Validated;
By intersecting these, you’re creating a "token" that functions can require. If a function signature asks for a ValidatedEmail, the caller cannot simply pass a raw string. They must pass a value that has passed through your validation layer. This is a massive step up from relying on simple primitive types, much like how TypeScript Value Objects: Eliminating Primitive Obsession in Your Code helps keep your domain logic clean and predictable.
I usually pair this with a runtime library like Zod. You can read more about why TypeScript Zod Schema Validation: A Guide to Runtime Type Safety is the standard for bridging the gap between external input and your internal domain logic.
Here is how I structure the flow:
TYPESCRIPTconst EmailSchema = z.string().email(); function createValidatedEmail(input: string): ValidatedEmail { const result = EmailSchema.parse(input); // Casting is safe here because we've validated the input return result as ValidatedEmail; }
This ensures that the "brand" stays attached to the data as it moves through your services. It makes the code self-documenting; when you see ValidatedEmail in a function argument, you know exactly what that string has been through.
Honestly, this approach isn't free. It adds boilerplate. You’ll have to write "constructor" functions to wrap your types, and you’ll occasionally find yourself needing to cast types using as when the compiler gets too strict.
I’ve found the trade-off is worth it for core domain entities. However, don't try to brand everything. If you brand every single string in your application, you’ll spend more time fighting the compiler than writing features. Reserve these techniques for critical identifiers, money amounts, or status flags where a mistake results in real-world consequences.
Not significantly. Since these brands are erased at compile-time (they are just metadata for the compiler), there is zero overhead in your production bundle. The only cost is the runtime validation you perform (like Zod schema checks).
Yes. You can use intersection types to add a brand to an existing interface. For example, User & { __brand: 'AuthorizedUser' } allows you to treat a standard User object as a privileged entity within specific service layers.
It requires discipline. You need a centralized place to define your branded types. If you change a brand, you have to update your validation functions. It’s a bit more work upfront, but it’s vastly easier than hunting down bugs in production caused by passing the wrong ID to a critical service.
Ultimately, using TypeScript branded types and intersections is about shifting the burden of verification from the developer's memory to the compiler's engine. I’m still experimenting with how to make the syntax less verbose, but the peace of mind during refactoring is worth every extra line of code.
Preventing runtime property errors is easier with TypeScript. Learn to use keyof and mapped types for safe dynamic object access in your next refactor.