UserId and a PostId when both are just basic strings at runtime?1. The Problem: Structural Equivalence#
In a Nominal Type System (like Java, C#, or Go), the compiler throws an error if you pass a variable of type PostId into a function expecting UserId, because the names of the types are different.
In TypeScript’s Structural System, type names are just aliases. They don’t mean anything.
type UserId = string;
type PostId = string;
function deleteUser(id: UserId) {
// Database deletion logic
}
const myPostId: PostId = "post_999";
// 🔴 DANGER!
// The compiler allows this because both are just structurally 'string'!
// You just deleted a user by passing a post ID.
deleteUser(myPostId);2. The Solution: Branding (Opaque Types)#
To force TypeScript to treat them differently, we must change their structure. We do this by attaching a fake, “phantom” property to the primitive type. This property does not exist at runtime, but the compiler sees it in Type Space.
This technique is called Branding (or creating Opaque Types).
// We intersect the primitive type K with a unique structural property.
type Brand<K, T> = K & { __brand: T };
// Now they are structurally different!
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
function deleteUser(id: UserId) {
// Logic
}
// 🟢 You must explicitly cast the string to a Branded type when it enters your system
const myUserId = "user_1" as UserId;
const myPostId = "post_1" as PostId;
deleteUser(myUserId); // SAFE!
// 🔴 ERROR! Argument of type 'PostId' is not assignable to parameter of type 'UserId'.
// Types of property '__brand' are incompatible.
// deleteUser(myPostId);
Why use an Intersection?#
By using an intersection (&), the core type remains a standard string. The __brand is just a hallucination for the compiler. When deleteUser receives the UserId, it still possesses all the standard prototype methods of a string:
function deleteUser(id: UserId) {
// Valid! It's still fundamentally a string!
const upper = id.toUpperCase();
}3. Flavoring (Optional Branding)#
Branding is incredibly strict. If you attempt to pass a heavily branded string into a generic 3rd-party library function that expects a standard string, it will sometimes complain.
If you want a slightly softer approach, you can use Flavoring.
Flavoring makes the phantom property optional (?). This allows you to pass a branded type into a generic function, but still prevents you from passing a different branded type into a highly specific function.
type Flavor<K, T> = K & { __flavor?: T };
type EmailAddress = Flavor<string, "Email">;
type HomeAddress = Flavor<string, "Address">;
function sendEmail(email: EmailAddress) {
// Logic
}
// You still have to cast when it enters the system boundary
const validEmail = "test@test.com" as EmailAddress;
const home = "123 Main St" as HomeAddress;
// 🔴 Prevented!
// sendEmail(home);
// 🟢 The benefit: You can pass an EmailAddress to a generic string function without issues
function getLength(str: string) { return str.length; }
getLength(validEmail); // Works perfectly!
Summary & Next Steps#
In this episode:
- We discussed the limitations of Structural Typing when dealing with generic primitives like IDs.
- We implemented Branding by intersecting primitives with phantom
__brandproperties. - We implemented Flavoring by making the brand optional, allowing interoperability with generic libraries.
Branding is the ultimate defense mechanism for Domain-Driven Design (DDD) in TypeScript, ensuring you never mix up UUIDs, currencies, encrypted strings, or sanitized HTML.
In Episode 54: The Builder Pattern with Generics, we will explore how to chain generic methods together to build complex configurations step-by-step!

