Introduction to Branded Types
TypeScript uses structural typing by default, meaning types are compatible based on their structure rather than their names. However, there are cases where you need nominal typing behavior - distinguishing types based on their identity rather than just structure. Branded types provide this capability through a clever technique.
The Problem with Structural Typing
Consider this scenario:
type UserId = number;
type ProductId = number;
function getUser(id: UserId): string {
return `User ${id}`;
}
const productId: ProductId = 42;
getUser(productId); // No error! But this is semantically wrong
Both UserId and ProductId are structurally identical (just number), so TypeScript allows them to be used interchangeably. This can lead to subtle bugs.
Creating Branded Types
Branded types add a unique phantom property to distinguish types:
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<number, 'UserId'>;
type ProductId = Brand<number, 'ProductId'>;
function getUser(id: UserId): string {
return `User ${id}`;
}
// Type assertion needed to create branded values
const userId = 42 as UserId;
const productId = 42 as ProductId;
getUser(userId); // ✓ OK
getUser(productId); // ✗ Error: ProductId is not assignable to UserId
Note: The __brand property is a phantom type - it exists only at compile time and has no runtime representation. This means branded types have zero runtime overhead.
Factory Functions for Type Safety
Instead of type assertions everywhere, create factory functions:
type UserId = Brand<number, 'UserId'>;
type Email = Brand<string, 'Email'>;
function createUserId(id: number): UserId {
if (id <= 0) {
throw new Error('Invalid user ID');
}
return id as UserId;
}
function createEmail(email: string): Email {
if (!email.includes('@')) {
throw new Error('Invalid email format');
}
return email as Email;
}
// Now you have runtime validation + compile-time safety
const user = createUserId(42);
const email = createEmail('user@example.com');
Multi-Level Branding
You can create hierarchies of branded types:
type Id = Brand<number, 'Id'>;
type UserId = Brand<Id, 'UserId'>;
type AdminId = Brand<UserId, 'AdminId'>;
function processId(id: Id): void {
console.log(`Processing ID: ${id}`);
}
function processUser(id: UserId): void {
console.log(`Processing user: ${id}`);
}
function processAdmin(id: AdminId): void {
console.log(`Processing admin: ${id}`);
}
const adminId = 1 as AdminId;
processAdmin(adminId); // ✓ OK
processUser(adminId); // ✓ OK (AdminId extends UserId)
processId(adminId); // ✓ OK (AdminId extends Id)
const userId = 2 as UserId;
processAdmin(userId); // ✗ Error: UserId not assignable to AdminId
Branded Types for Units of Measure
Prevent mixing incompatible units:
type Meters = Brand<number, 'Meters'>;
type Kilometers = Brand<number, 'Kilometers'>;
type Miles = Brand<number, 'Miles'>;
const meters = (value: number) => value as Meters;
const kilometers = (value: number) => value as Kilometers;
const miles = (value: number) => value as Miles;
function addMeters(a: Meters, b: Meters): Meters {
return (a + b) as Meters;
}
function convertKmToMeters(km: Kilometers): Meters {
return (km * 1000) as Meters;
}
const distance1 = meters(100);
const distance2 = meters(50);
const distance3 = kilometers(5);
addMeters(distance1, distance2); // ✓ OK
addMeters(distance1, convertKmToMeters(distance3)); // ✓ OK
addMeters(distance1, distance3); // ✗ Error: Kilometers not assignable
Opaque Types Pattern
Create truly opaque types that hide implementation details:
// Declare the brand but never export the actual type
declare const opaqueUserId: unique symbol;
export type UserId = number & { readonly [opaqueUserId]: true };
// Only export factory and accessor functions
export function createUserId(id: number): UserId {
if (id <= 0) throw new Error('Invalid ID');
return id as UserId;
}
export function getUserIdValue(id: UserId): number {
return id as number;
}
// Usage in another file
import { UserId, createUserId, getUserIdValue } from './userId';
const id = createUserId(42);
const rawValue = getUserIdValue(id);
// Cannot create UserId directly - must use factory
const invalid = 42 as UserId; // Still possible, but discouraged
Best Practice: Use opaque types for domain-specific types that should have controlled creation and access. This enforces proper encapsulation at the type level.
Branded Types with Validation
Combine branding with runtime validation:
type PositiveNumber = Brand<number, 'PositiveNumber'>;
type Percentage = Brand<number, 'Percentage'>;
type Age = Brand<number, 'Age'>;
function assertPositive(n: number): asserts n is PositiveNumber {
if (n <= 0) {
throw new Error('Number must be positive');
}
}
function assertPercentage(n: number): asserts n is Percentage {
if (n < 0 || n > 100) {
throw new Error('Percentage must be between 0 and 100');
}
}
function assertAge(n: number): asserts n is Age {
if (n < 0 || n > 150) {
throw new Error('Invalid age');
}
}
function calculateDiscount(price: PositiveNumber, discount: Percentage): PositiveNumber {
const result = price * (1 - discount / 100);
return result as PositiveNumber;
}
const price = 100;
assertPositive(price);
// Now price is typed as PositiveNumber
const discount = 20;
assertPercentage(discount);
// Now discount is typed as Percentage
const finalPrice = calculateDiscount(price, discount);
Type-Safe String Patterns
Use branded types for validated string patterns:
type UUID = Brand<string, 'UUID'>;
type HexColor = Brand<string, 'HexColor'>;
type URL = Brand<string, 'URL'>;
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HEX_COLOR_REGEX = /^#[0-9a-f]{6}$/i;
const URL_REGEX = /^https?:\/\/.+/;
function createUUID(str: string): UUID {
if (!UUID_REGEX.test(str)) {
throw new Error('Invalid UUID format');
}
return str as UUID;
}
function createHexColor(str: string): HexColor {
if (!HEX_COLOR_REGEX.test(str)) {
throw new Error('Invalid hex color format');
}
return str as HexColor;
}
function createURL(str: string): URL {
if (!URL_REGEX.test(str)) {
throw new Error('Invalid URL format');
}
return str as URL;
}
function setBackgroundColor(color: HexColor): void {
document.body.style.backgroundColor = color;
}
const color = createHexColor('#ff5733');
setBackgroundColor(color); // ✓ Type-safe
setBackgroundColor('#ff5733'); // ✗ Error: string not assignable to HexColor
Nominal Typing with Classes
Classes in TypeScript already have nominal typing characteristics:
class UserId {
constructor(private value: number) {
if (value <= 0) throw new Error('Invalid ID');
}
getValue(): number {
return this.value;
}
}
class ProductId {
constructor(private value: number) {
if (value <= 0) throw new Error('Invalid ID');
}
getValue(): number {
return this.value;
}
}
function getUser(id: UserId): string {
return `User ${id.getValue()}`;
}
const userId = new UserId(42);
const productId = new ProductId(42);
getUser(userId); // ✓ OK
getUser(productId); // ✗ Error: ProductId not assignable to UserId
Warning: Classes have runtime overhead (memory and performance) compared to branded types, which are purely compile-time constructs. Choose based on your needs.
Advanced Branding Techniques
Use unique symbols for stronger guarantees:
declare const userIdBrand: unique symbol;
declare const productIdBrand: unique symbol;
type UserId = number & { readonly [userIdBrand]: true };
type ProductId = number & { readonly [productIdBrand]: true };
// Helper for creating branded types
type Branded<T, Brand extends symbol> = T & { readonly [K in Brand]: true };
declare const emailBrand: unique symbol;
declare const urlBrand: unique symbol;
type Email = Branded<string, typeof emailBrand>;
type URL = Branded<string, typeof urlBrand>;
function sendEmail(to: Email, subject: string, body: string): void {
console.log(`Sending to ${to}: ${subject}`);
}
const email = 'user@example.com' as Email;
sendEmail(email, 'Hello', 'World');
Real-World Example: Financial System
type Currency = 'USD' | 'EUR' | 'GBP';
declare const moneyBrand: unique symbol;
type Money<C extends Currency> = {
readonly amount: number;
readonly currency: C;
readonly [moneyBrand]: true;
};
function createMoney<C extends Currency>(
amount: number,
currency: C
): Money<C> {
if (amount < 0) throw new Error('Amount cannot be negative');
return { amount, currency, [moneyBrand]: true } as Money<C>;
}
function addMoney<C extends Currency>(
a: Money<C>,
b: Money<C>
): Money<C> {
return createMoney(a.amount + b.amount, a.currency);
}
function convertCurrency<From extends Currency, To extends Currency>(
money: Money<From>,
toCurrency: To,
rate: number
): Money<To> {
return createMoney(money.amount * rate, toCurrency);
}
const usd100 = createMoney(100, 'USD');
const usd50 = createMoney(50, 'USD');
const eur80 = createMoney(80, 'EUR');
addMoney(usd100, usd50); // ✓ OK: both USD
addMoney(usd100, eur80); // ✗ Error: cannot mix currencies
// Must convert first
const eur80AsUsd = convertCurrency(eur80, 'USD', 1.1);
addMoney(usd100, eur80AsUsd); // ✓ OK: both USD now
Exercise:
- Create branded types for
Temperature with units (Celsius, Fahrenheit, Kelvin)
- Implement conversion functions between units
- Create a
WeatherReport type that ensures temperature units are consistent
- Add validation to prevent invalid temperatures (e.g., below absolute zero)