Prerequisites
- Solid understanding of TypeScript's structural type system π
- Experience with advanced type patterns and generics β‘
- Knowledge of utility types and conditional types π»
What you'll learn
- Understand structural vs nominal typing systems π―
- Implement brand types for distinct type identities ποΈ
- Create type-safe domain models with brand types π
- Apply nominal typing patterns in real-world scenarios β¨
π― Introduction
Welcome to the world of nominal typing in TypeScript! π This tutorial explores how to simulate nominal typing using brand types, allowing you to create distinct types that prevent common errors even when the underlying structures are identical.
Youβll discover how brand types can transform your domain modeling by making it impossible to accidentally mix up similar types like UserId
and ProductId
, even though theyβre both strings. Whether youβre building financial systems π°, medical applications π₯, or any domain where type confusion could be dangerous π¨, nominal types provide an extra layer of safety that goes beyond TypeScriptβs structural typing.
By the end of this tutorial, youβll be creating rock-solid type boundaries that make impossible states truly impossible! Letβs brand our types for safety! π·οΈ
π Understanding Structural vs Nominal Typing
π€ What is Structural Typing?
TypeScript uses structural typing, where types are compatible if their structure matches ποΈ. This is different from nominal typing, where types are compatible only if they have the same name/brand.
// π― Structural typing in TypeScript
interface Point2D {
x: number;
y: number;
}
interface Vector2D {
x: number;
y: number;
}
// β
These are structurally compatible!
const point: Point2D = { x: 10, y: 20 };
const vector: Vector2D = point; // No error - same structure
// π€ But conceptually, a point and vector are different things!
function movePoint(point: Point2D, vector: Vector2D): Point2D {
return { x: point.x + vector.x, y: point.y + vector.y };
}
// π₯ This compiles but might be semantically wrong!
const newPoint = movePoint(vector, point); // Swapped arguments!
π‘ The Problems with Structural Typing
Here are real-world issues that structural typing can cause:
- Parameter Confusion π: Similar types get mixed up in function calls
- Domain Boundary Violations π§: Different domain concepts become interchangeable
- Refactoring Dangers β οΈ: Structure changes affect unrelated types
- Semantic Meaning Loss π: Type names become just documentation
Real-world example: In a financial system, AccountId
, TransactionId
, and UserId
might all be strings, but mixing them up could be catastrophic! πΈ
π·οΈ Brand Types: Simulating Nominal Typing
π― The Brand Type Pattern
Brand types add unique markers to create distinct types with identical runtime values:
// π Basic brand type pattern
declare const Brand: unique symbol;
type Branded<T, TBrand> = T & {
readonly [Brand]: TBrand;
};
// π·οΈ Create distinct branded types
type UserId = Branded<string, 'UserId'>;
type ProductId = Branded<string, 'ProductId'>;
type EmailAddress = Branded<string, 'EmailAddress'>;
// π¨ Brand constructor functions
const UserId = (value: string): UserId => value as UserId;
const ProductId = (value: string): ProductId => value as ProductId;
const EmailAddress = (value: string): EmailAddress => value as EmailAddress;
// π§ͺ Usage - type safety in action!
const userId = UserId("user_123");
const productId = ProductId("product_456");
const email = EmailAddress("[email protected]");
// β
Functions now enforce correct types
function getUserData(id: UserId): Promise<User> {
return fetch(`/api/users/${id}`).then(r => r.json());
}
function getProductData(id: ProductId): Promise<Product> {
return fetch(`/api/products/${id}`).then(r => r.json());
}
// β
Correct usage
getUserData(userId); // β
Works
getProductData(productId); // β
Works
// β Type errors prevent mistakes!
// getUserData(productId); // β Error: ProductId not assignable to UserId
// getProductData(userId); // β Error: UserId not assignable to ProductId
// getUserData("user_123"); // β Error: string not assignable to UserId
// π― But runtime values are still just strings!
console.log(typeof userId); // "string"
console.log(userId.length); // 8
console.log(userId + "_copy"); // "user_123_copy"
ποΈ Advanced Brand Type Utilities
// π More sophisticated brand utilities
type Brand<K, T> = K & { __brand: T };
// π¨ Smart constructors with validation
type PositiveNumber = Brand<number, 'PositiveNumber'>;
type EmailAddress = Brand<string, 'EmailAddress'>;
type NonEmptyString = Brand<string, 'NonEmptyString'>;
// π‘οΈ Validated constructors
function createPositiveNumber(value: number): PositiveNumber | null {
return value> 0 ? (value as PositiveNumber) : null;
}
function createEmailAddress(value: string): EmailAddress | null {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value) ? (value as EmailAddress) : null;
}
function createNonEmptyString(value: string): NonEmptyString | null {
return value.trim().length> 0 ? (value as NonEmptyString) : null;
}
// π― Type-safe validation pipeline
type ValidationResult<T> = { success: true; data: T } | { success: false; error: string };
function safeCreatePositiveNumber(value: number): ValidationResult<PositiveNumber> {
if (value <= 0) {
return { success: false, error: "Number must be positive" };
}
return { success: true, data: value as PositiveNumber };
}
function safeCreateEmailAddress(value: string): ValidationResult<EmailAddress> {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return { success: false, error: "Invalid email format" };
}
return { success: true, data: value as EmailAddress };
}
// π§ͺ Usage with validation
const priceResult = safeCreatePositiveNumber(25.99);
if (priceResult.success) {
console.log(`Price: $${priceResult.data}`); // Type is PositiveNumber
}
const emailResult = safeCreateEmailAddress("[email protected]");
if (emailResult.success) {
console.log(`Email: ${emailResult.data}`); // Type is EmailAddress
}
π¨ Real-World Brand Type Applications
π¦ Financial Domain Model
// π¦ Financial types with brand safety
type Money = Brand<number, 'Money'>;
type AccountNumber = Brand<string, 'AccountNumber'>;
type RoutingNumber = Brand<string, 'RoutingNumber'>;
type TransactionId = Brand<string, 'TransactionId'>;
// π° Currency-aware money types
type USD = Brand<Money, 'USD'>;
type EUR = Brand<Money, 'EUR'>;
type GBP = Brand<Money, 'GBP'>;
// ποΈ Smart constructors with validation
function createMoney(amount: number): Money {
if (!Number.isFinite(amount)) {
throw new Error("Money amount must be finite");
}
if (Math.round(amount * 100) !== amount * 100) {
throw new Error("Money amount cannot have more than 2 decimal places");
}
return amount as Money;
}
function createUSD(amount: Money): USD {
return amount as USD;
}
function createEUR(amount: Money): EUR {
return amount as EUR;
}
function createAccountNumber(value: string): AccountNumber {
if (!/^\d{8,12}$/.test(value)) {
throw new Error("Account number must be 8-12 digits");
}
return value as AccountNumber;
}
function createRoutingNumber(value: string): RoutingNumber {
if (!/^\d{9}$/.test(value)) {
throw new Error("Routing number must be 9 digits");
}
return value as RoutingNumber;
}
// π― Domain operations with type safety
interface BankAccount {
accountNumber: AccountNumber;
routingNumber: RoutingNumber;
balance: USD; // Specific currency
owner: UserId;
}
interface Transaction {
id: TransactionId;
from: AccountNumber;
to: AccountNumber;
amount: USD;
timestamp: Date;
memo?: string;
}
// π³ Type-safe banking operations
function transfer(
fromAccount: BankAccount,
toAccount: BankAccount,
amount: USD
): Transaction {
if (amount as number <= 0) {
throw new Error("Transfer amount must be positive");
}
if ((fromAccount.balance as number) < (amount as number)) {
throw new Error("Insufficient funds");
}
return {
id: `txn_${Date.now()}` as TransactionId,
from: fromAccount.accountNumber,
to: toAccount.accountNumber,
amount,
timestamp: new Date()
};
}
// β
Type-safe currency operations
function addUSD(a: USD, b: USD): USD {
return createUSD(createMoney((a as number) + (b as number)));
}
function subtractUSD(a: USD, b: USD): USD {
return createUSD(createMoney((a as number) - (b as number)));
}
// β This would prevent mixing currencies at compile time!
// function addMixedCurrency(usd: USD, eur: EUR): Money {
// return createMoney((usd as number) + (eur as number)); // Semantic error!
// }
// π§ͺ Usage
const account1: BankAccount = {
accountNumber: createAccountNumber("123456789"),
routingNumber: createRoutingNumber("987654321"),
balance: createUSD(createMoney(1500.00)),
owner: UserId("user_123")
};
const account2: BankAccount = {
accountNumber: createAccountNumber("987654321"),
routingNumber: createRoutingNumber("123456789"),
balance: createUSD(createMoney(500.00)),
owner: UserId("user_456")
};
const transferAmount = createUSD(createMoney(250.00));
const transaction = transfer(account1, account2, transferAmount);
console.log(`Transfer of $${transferAmount} completed: ${transaction.id}`);
π₯ Medical System with Type Safety
// π₯ Medical domain with strict typing
type PatientId = Brand<string, 'PatientId'>;
type MedicalRecordNumber = Brand<string, 'MedicalRecordNumber'>;
type PrescriptionId = Brand<string, 'PrescriptionId'>;
type DrugCode = Brand<string, 'DrugCode'>;
type Dosage = Brand<number, 'Dosage'>;
// π Dosage units
type Milligrams = Brand<Dosage, 'Milligrams'>;
type Milliliters = Brand<Dosage, 'Milliliters'>;
type Units = Brand<Dosage, 'Units'>;
// π¬ Lab values with units
type BloodPressure = Brand<{ systolic: number; diastolic: number }, 'BloodPressure'>;
type Temperature = Brand<number, 'Temperature'>;
type Celsius = Brand<Temperature, 'Celsius'>;
type Fahrenheit = Brand<Temperature, 'Fahrenheit'>;
// ποΈ Medical constructors
function createPatientId(value: string): PatientId {
if (!/^P\d{6}$/.test(value)) {
throw new Error("Patient ID must be format P123456");
}
return value as PatientId;
}
function createMedicalRecordNumber(value: string): MedicalRecordNumber {
if (!/^MR\d{8}$/.test(value)) {
throw new Error("Medical record number must be format MR12345678");
}
return value as MedicalRecordNumber;
}
function createDrugCode(value: string): DrugCode {
if (!/^[A-Z]{3}\d{4}$/.test(value)) {
throw new Error("Drug code must be format ABC1234");
}
return value as DrugCode;
}
function createMilligrams(amount: number): Milligrams {
if (amount <= 0) {
throw new Error("Dosage must be positive");
}
return amount as Milligrams;
}
function createCelsius(temp: number): Celsius {
if (temp < -273.15) {
throw new Error("Temperature cannot be below absolute zero");
}
return temp as Celsius;
}
function createBloodPressure(systolic: number, diastolic: number): BloodPressure {
if (systolic <= 0 || diastolic <= 0) {
throw new Error("Blood pressure values must be positive");
}
if (systolic <= diastolic) {
throw new Error("Systolic pressure must be higher than diastolic");
}
return { systolic, diastolic } as BloodPressure;
}
// π₯ Medical data structures
interface Patient {
id: PatientId;
recordNumber: MedicalRecordNumber;
name: string;
birthDate: Date;
allergies: DrugCode[];
}
interface Prescription {
id: PrescriptionId;
patientId: PatientId;
drugCode: DrugCode;
dosage: Milligrams;
frequency: string;
prescribedBy: UserId;
validUntil: Date;
}
interface VitalSigns {
patientId: PatientId;
temperature: Celsius;
bloodPressure: BloodPressure;
heartRate: number;
timestamp: Date;
}
// π Safe prescription operations
function prescribeMedication(
patient: Patient,
drugCode: DrugCode,
dosage: Milligrams,
frequency: string,
doctor: UserId
): Prescription | { error: string } {
// Check for allergies
if (patient.allergies.includes(drugCode)) {
return { error: `Patient allergic to drug ${drugCode}` };
}
return {
id: `RX_${Date.now()}` as PrescriptionId,
patientId: patient.id,
drugCode,
dosage,
frequency,
prescribedBy: doctor,
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
};
}
// π‘οΈ Temperature conversion with type safety
function celsiusToFahrenheit(celsius: Celsius): Fahrenheit {
const fahrenheit = (celsius as number) * 9/5 + 32;
return fahrenheit as Fahrenheit;
}
function fahrenheitToCelsius(fahrenheit: Fahrenheit): Celsius {
const celsius = ((fahrenheit as number) - 32) * 5/9;
return celsius as Celsius;
}
// π§ͺ Medical system usage
const patient = {
id: createPatientId("P123456"),
recordNumber: createMedicalRecordNumber("MR12345678"),
name: "John Doe",
birthDate: new Date("1980-01-01"),
allergies: [createDrugCode("PEN1234")] // Allergic to penicillin
};
const vitals: VitalSigns = {
patientId: patient.id,
temperature: createCelsius(37.2),
bloodPressure: createBloodPressure(120, 80),
heartRate: 72,
timestamp: new Date()
};
const prescriptionResult = prescribeMedication(
patient,
createDrugCode("IBU5678"), // Ibuprofen
createMilligrams(400),
"Every 6 hours",
UserId("doctor_789")
);
if ('error' in prescriptionResult) {
console.log(`Prescription failed: ${prescriptionResult.error}`);
} else {
console.log(`Prescription created: ${prescriptionResult.id}`);
}
π§ Advanced Brand Type Patterns
π― Phantom Types for State Machines
// π Phantom types for state tracking
type State = 'Draft' | 'Submitted' | 'Approved' | 'Rejected';
type Document<TState extends State> = {
id: string;
content: string;
author: UserId;
createdAt: Date;
} & Brand<{}, TState>;
// ποΈ State-specific operations
function createDraft(content: string, author: UserId): Document<'Draft'> {
return {
id: `doc_${Date.now()}`,
content,
author,
createdAt: new Date()
} as Document<'Draft'>;
}
function submitDocument(doc: Document<'Draft'>): Document<'Submitted'> {
// Only draft documents can be submitted
return doc as Document<'Submitted'>;
}
function approveDocument(doc: Document<'Submitted'>): Document<'Approved'> {
// Only submitted documents can be approved
return doc as Document<'Approved'>;
}
function rejectDocument(doc: Document<'Submitted'>): Document<'Rejected'> {
// Only submitted documents can be rejected
return doc as Document<'Rejected'>;
}
// π― Type-safe workflow
const draft = createDraft("Document content", UserId("author_123"));
const submitted = submitDocument(draft);
const approved = approveDocument(submitted);
// β These would be compile-time errors:
// approveDocument(draft); // Error: Draft cannot be approved directly
// submitDocument(approved); // Error: Approved cannot be resubmitted
π URL and URI Safety
// π Type-safe URLs
type URL = Brand<string, 'URL'>;
type RelativeURL = Brand<string, 'RelativeURL'>;
type AbsoluteURL = Brand<string, 'AbsoluteURL'>;
type EmailURL = Brand<AbsoluteURL, 'EmailURL'>;
type HTTPURL = Brand<AbsoluteURL, 'HTTPURL'>;
// π URL constructors with validation
function createURL(value: string): URL | null {
try {
new globalThis.URL(value);
return value as URL;
} catch {
return null;
}
}
function createRelativeURL(value: string): RelativeURL | null {
if (value.startsWith('/') && !value.includes('://')) {
return value as RelativeURL;
}
return null;
}
function createAbsoluteURL(value: string): AbsoluteURL | null {
const url = createURL(value);
if (url && value.includes('://')) {
return value as AbsoluteURL;
}
return null;
}
function createHTTPURL(value: string): HTTPURL | null {
const absoluteURL = createAbsoluteURL(value);
if (absoluteURL && (value.startsWith('http://') || value.startsWith('https://'))) {
return value as HTTPURL;
}
return null;
}
// π Type-safe URL operations
function fetchFromHTTP(url: HTTPURL): Promise<Response> {
return fetch(url as string);
}
function redirectToRelative(url: RelativeURL): void {
window.location.href = url as string;
}
function combineURLs(base: AbsoluteURL, relative: RelativeURL): AbsoluteURL {
const combined = new globalThis.URL(relative as string, base as string);
return combined.href as AbsoluteURL;
}
// β
Type-safe usage
const apiUrl = createHTTPURL("https://api.example.com");
const profilePath = createRelativeURL("/profile");
if (apiUrl && profilePath) {
const profileUrl = combineURLs(apiUrl, profilePath);
console.log(`Profile URL: ${profileUrl}`);
}
π οΈ Best Practices for Brand Types
- π― Use Descriptive Brand Names: Make the purpose clear in the type name
- π Create Smart Constructors: Always validate when creating branded values
- π Keep Runtime Performance: Brands are compile-time only, no runtime cost
- π‘οΈ Validate at Boundaries: Create branded types at system boundaries
- β¨ Use Phantom Types for States: Track state transitions with the type system
- π¨ Compose Brands: Build complex brands from simpler ones
- π‘ Document Brand Invariants: Clearly state what each brand guarantees
- π Test Brand Boundaries: Verify that invalid constructions fail
π§ͺ Hands-On Exercise
π― Challenge: Build a Type-Safe E-commerce System
Create a complete e-commerce domain model with brand types:
π Requirements:
- β¨ Product catalog with SKUs, prices, and inventory
- π Shopping cart with quantity validation
- π³ Payment processing with currency safety
- π¦ Order management with status tracking
- π Type-safe state transitions
π Bonus Points:
- Add discount codes with validation
- Implement tax calculation with regional types
- Create inventory management with stock levels
- Build a return/refund system
π‘ Solution Preview
π Click to see partial solution
// πͺ E-commerce brand types
type ProductSKU = Brand<string, 'ProductSKU'>;
type Price = Brand<number, 'Price'>;
type Quantity = Brand<number, 'Quantity'>;
type InventoryLevel = Brand<number, 'InventoryLevel'>;
type OrderId = Brand<string, 'OrderId'>;
type CartId = Brand<string, 'CartId'>;
// π° Currency-specific prices
type USDPrice = Brand<Price, 'USD'>;
type EURPrice = Brand<Price, 'EUR'>;
// π¦ Order states
type Order<TState extends 'Pending' | 'Confirmed' | 'Shipped' | 'Delivered'> = {
id: OrderId;
items: OrderItem[];
total: USDPrice;
customer: UserId;
shippingAddress: Address;
} & Brand<{}, TState>;
// π Smart constructors
function createProductSKU(value: string): ProductSKU | null {
return /^[A-Z]{2}\d{6}$/.test(value) ? value as ProductSKU : null;
}
function createQuantity(value: number): Quantity | null {
return Number.isInteger(value) && value> 0 ? value as Quantity : null;
}
function createUSDPrice(cents: number): USDPrice | null {
return Number.isInteger(cents) && cents>= 0 ? cents as USDPrice : null;
}
// π― Type-safe operations
function addToCart(cart: ShoppingCart, sku: ProductSKU, quantity: Quantity): ShoppingCart {
// Implementation with type safety
}
function checkout(cart: ShoppingCart): Order<'Pending'> {
// Implementation with type safety
}
function confirmOrder(order: Order<'Pending'>): Order<'Confirmed'> {
// State transition with type safety
}
console.log("π Type-safe e-commerce system ready!");
π Key Takeaways
Youβve mastered TypeScriptβs nominal typing patterns! Hereβs what you now control:
- β Brand type fundamentals and the unique symbol pattern πͺ
- β Smart constructors with validation and error handling π‘οΈ
- β Domain modeling with type-safe boundaries π―
- β State machine types using phantom type parameters π
- β Real-world applications in finance, medical, and web domains π
- β Advanced patterns for URL safety and workflow management β¨
- β Performance considerations and best practices π
Remember: Brand types give you nominal typing superpowers in a structural type system! π€
π€ Next Steps
Congratulations! π Youβve mastered nominal typing in TypeScript!
Hereβs what to do next:
- π» Refactor existing codebases to use brand types for critical domains
- ποΈ Build domain-specific type libraries with branded safety
- π Explore more advanced type system patterns and techniques
- π Apply these patterns to prevent bugs in production systems
- π Study other languagesβ nominal type systems for inspiration
- π― Teach others about the power of compile-time safety
Remember: Brand types are your secret weapon for building bulletproof TypeScript applications! π
Happy brand type mastering! ππβ¨