🚀AdvancedType System Mastery
Nominal Typing Patterns
Simulate nominal (name-based) typing in TypeScript's structural system using branding, opaque types, and unique symbols to prevent value confusion.
Overview
TypeScript uses **structural typing**: two types are compatible if they have the same shape, regardless of their names. This is usually convenient, but sometimes you want **nominal typing**: two types are only compatible if they are the same named type, even if structurally identical.
The classic problem is IDs: `UserId` and `PostId` are both strings, so TypeScript lets you pass one where the other is expected. Nominal typing prevents this.
There are three main patterns for simulating nominal types in TypeScript:
1. **Brand intersection** - intersect with a phantom type literal
2. **Unique symbol brand** - use a unique symbol as the phantom tag (strongest)
3. **Class with private field** - use a class whose private field prevents structural compatibility
Each pattern trades off ergonomics vs. safety vs. performance. The brand intersection approach is the most common and works well for most use cases.
Key Points
- •Structural typing: TypeScript checks shape, not name - two identical-shaped types are compatible
- •Nominal typing simulation: add a "phantom" property that only exists at the type level
- •Brand pattern: `type UserId = string & { readonly _brand: "UserId" }`
- •Unique symbol brand: strongest form - `declare const _brand: unique symbol; type UserId = string & { [_brand]: "UserId" }`
- •Smart constructors: functions that validate and return the branded type
- •Classes with private fields achieve nominal typing naturally (private members block structural compatibility)
- •Use branded types for: IDs, units of measure, validated strings (email, URL), sanitized inputs
Examples
Brand intersection pattern
// Simple brand using intersection
type Brand<T, B extends string> = T & { readonly _brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
type Email = Brand<string, 'Email'>;
type Dollars = Brand<number, 'Dollars'>;
type Cents = Brand<number, 'Cents'>;
// Smart constructors validate and brand
function UserId(s: string): UserId {
if (s.length === 0) throw new Error('UserId cannot be empty');
return s as UserId;
}
function Email(s: string): Email {
if (!s.includes('@')) throw new Error(`Invalid email: ${s}`);
return s.toLowerCase() as Email;
}
function Dollars(n: number): Dollars {
if (n < 0) throw new Error('Dollars cannot be negative');
return n as Dollars;
}
// Functions that require specific branded types
function getUser(id: UserId): string { return `User:${id}`; }
function getPost(id: PostId): string { return `Post:${id}`; }
function sendEmail(to: Email, amount: Dollars): void {
console.log(`Sending receipt for $${amount} to ${to}`);
}
const uid = UserId('usr_123');
const pid = 'post_456' as PostId; // cast when you trust the source
const email = Email('alice@example.com');
const price = Dollars(9.99);
getUser(uid); // OK
// getUser(pid); // Error - PostId is not UserId
// getUser('usr_789'); // Error - plain string is not UserId
sendEmail(email, price); // OKUnique symbol brand for strongest nominal typing
// Unique symbols are truly unique - cannot be spoofed
declare const _userId: unique symbol;
declare const _postId: unique symbol;
declare const _orderId: unique symbol;
type UserId = string & { readonly [_userId]: never };
type PostId = string & { readonly [_postId]: never };
type OrderId = string & { readonly [_orderId]: never };
function makeUserId(s: string): UserId { return s as UserId; }
function makePostId(s: string): PostId { return s as PostId; }
function makeOrderId(s: string): OrderId { return s as OrderId; }
const uid = makeUserId('u1');
const pid = makePostId('p1');
const oid = makeOrderId('o1');
function deleteUser(id: UserId): void {
console.log(`Deleting user ${id}`);
}
deleteUser(uid); // OK
// deleteUser(pid); // Error - completely incompatible unique symbol brands
// deleteUser('u1'); // Error - plain string has no [_userId] propertyClass-based nominal typing with private fields
// Private fields make classes structurally incompatible
class Celsius {
#celsius: number;
constructor(value: number) {
if (value < -273.15) throw new RangeError('Below absolute zero');
this.#celsius = value;
}
get value(): number { return this.#celsius; }
toFahrenheit(): Fahrenheit {
return new Fahrenheit(this.#celsius * 9 / 5 + 32);
}
}
class Fahrenheit {
#fahrenheit: number;
constructor(value: number) {
if (value < -459.67) throw new RangeError('Below absolute zero');
this.#fahrenheit = value;
}
get value(): number { return this.#fahrenheit; }
toCelsius(): Celsius {
return new Celsius((this.#fahrenheit - 32) * 5 / 9);
}
}
function setOvenTemp(temp: Fahrenheit): void {
console.log(`Oven: ${temp.value}F`);
}
function bodyTempAlert(temp: Celsius): void {
if (temp.value > 38) console.log('Fever!');
}
const oven = new Fahrenheit(375);
const body = new Celsius(37.5);
setOvenTemp(oven); // OK
// setOvenTemp(body); // Error - Celsius is not Fahrenheit (private field differs)
bodyTempAlert(body); // OK
console.log(body.toFahrenheit().value); // 99.5Common Mistakes
- Using `as` to create branded values everywhere without validation: the brand is only meaningful if you validate before branding - otherwise you get false security
- Naming the brand property something accessible like `type`: other objects might accidentally have a `type` field, making structural compatibility possible; use `_brand` or a unique symbol
- Trying to use brands for objects (not primitives): brand intersections work best for primitive types; for objects, classes with private fields or opaque module patterns work better
Note
Choose the simplest approach that works: brand intersection for most use cases (IDs, units), unique symbols when you need to prevent all accidental compatibility, and classes when you also need behavior attached to the value. Do not over-engineer - branded strings that are never validated give a false sense of security.
Exercises
Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.
Loading editor…
Completed: 0 / 2 exercises
Knowledge Check
Question 1 of 4