System Design & Architecture
Part 0 of 9Advanced TypeScript Patterns Every Architect Should Know
Advanced TypeScript Patterns Every Architect Should Know
Discriminated unions, branded types, template literal types, infer keyword — not for TypeScript gymnastics but for modeling domain logic so the compiler catches business rule violations.
The Thesis
Most TypeScript codebases use types for shape validation: "this object has these properties." That's the minimum. The real power is using types to encode business rules so invalid states become unrepresentable.
// Level 1: Shape validation (most codebases)
interface User {
id: string;
email: string;
status: string;
}
// Level 2: Business rule encoding (this article)
interface User {
id: UserId; // Not just any string
email: Email; // Validated email format
status: 'pending' | 'active' | 'banned'; // Explicit states
}
// Level 3: State machine modeling
type User = PendingUser | ActiveUser | BannedUser;
// Each state has different allowed operations
When the type system encodes your domain, wrong code doesn't compile. No unit tests needed for "what happens if we try to ban an already-banned user" — the types make it impossible to write.
Pattern 1: Discriminated Unions for State Machines
The Problem: String Status Fields
// The typical approach
interface Order {
id: string;
status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
paidAt?: Date;
shippedAt?: Date;
deliveredAt?: Date;
cancelledAt?: Date;
trackingNumber?: string;
refundAmount?: number;
}
function shipOrder(order: Order): Order {
// Runtime checks everywhere
if (order.status !== 'paid') {
throw new Error('Can only ship paid orders');
}
if (!order.paidAt) {
throw new Error('Paid order must have paidAt'); // Should be impossible but...
}
return {
...order,
status: 'shipped',
shippedAt: new Date(),
trackingNumber: generateTrackingNumber(),
};
}
// Problems:
// 1. Optional fields that aren't really optional (paidAt exists when paid)
// 2. Impossible states are representable ({ status: 'pending', deliveredAt: new Date() })
// 3. State transitions are runtime-checked, not compile-time enforced
// 4. Every function that handles orders needs defensive checks
The Solution: Discriminated Unions
// Each state is its own type with exactly the fields it needs
interface PendingOrder {
kind: 'pending';
id: OrderId;
items: OrderItem[];
createdAt: Date;
}
interface PaidOrder {
kind: 'paid';
id: OrderId;
items: OrderItem[];
createdAt: Date;
paidAt: Date;
paymentIntentId: string;
}
interface ShippedOrder {
kind: 'shipped';
id: OrderId;
items: OrderItem[];
createdAt: Date;
paidAt: Date;
paymentIntentId: string;
shippedAt: Date;
trackingNumber: string;
carrier: Carrier;
}
interface DeliveredOrder {
kind: 'delivered';
id: OrderId;
items: OrderItem[];
createdAt: Date;
paidAt: Date;
paymentIntentId: string;
shippedAt: Date;
trackingNumber: string;
carrier: Carrier;
deliveredAt: Date;
signedBy?: string;
}
interface CancelledOrder {
kind: 'cancelled';
id: OrderId;
items: OrderItem[];
createdAt: Date;
cancelledAt: Date;
cancellationReason: string;
// Note: no paidAt, shippedAt — cancelled orders might not have gone through those states
}
// The union
type Order = PendingOrder | PaidOrder | ShippedOrder | DeliveredOrder | CancelledOrder;
// State transitions are now type signatures
function payOrder(order: PendingOrder, paymentIntentId: string): PaidOrder {
return {
kind: 'paid',
id: order.id,
items: order.items,
createdAt: order.createdAt,
paidAt: new Date(),
paymentIntentId,
};
}
function shipOrder(order: PaidOrder, tracking: TrackingInfo): ShippedOrder {
return {
kind: 'shipped',
...order,
shippedAt: new Date(),
trackingNumber: tracking.number,
carrier: tracking.carrier,
};
}
// Type error: Cannot ship a pending order
shipOrder(pendingOrder, tracking);
// ^^^^^^^^^^^^
// Argument of type 'PendingOrder' is not assignable to parameter of type 'PaidOrder'.
// Type error: Cannot pay an already-paid order
payOrder(paidOrder, newPaymentIntent);
// ^^^^^^^^^
// Argument of type 'PaidOrder' is not assignable to parameter of type 'PendingOrder'.
Exhaustive Handling
The compiler ensures you handle all states:
function getOrderStatusMessage(order: Order): string {
switch (order.kind) {
case 'pending':
return 'Awaiting payment';
case 'paid':
return `Paid on ${order.paidAt.toLocaleDateString()}`;
case 'shipped':
return `Shipped via ${order.carrier} - ${order.trackingNumber}`;
case 'delivered':
return `Delivered on ${order.deliveredAt.toLocaleDateString()}`;
// Missing 'cancelled' case!
}
}
// Error: Function lacks ending return statement and return type does not include 'undefined'.
With noImplicitReturns and strict mode, forgotten cases are compile errors.
Encoding Valid Transitions
// Define valid state transitions as types
type OrderTransitions = {
pending: 'paid' | 'cancelled';
paid: 'shipped' | 'cancelled';
shipped: 'delivered';
delivered: never; // Terminal state
cancelled: never; // Terminal state
};
// Generic transition function that only allows valid transitions
function transitionOrder<
From extends Order['kind'],
To extends OrderTransitions[From]
>(
order: Extract<Order, { kind: From }>,
to: To,
data: TransitionData<From, To>
): Extract<Order, { kind: To }> {
// Implementation
}
// Valid: pending → paid
transitionOrder(pendingOrder, 'paid', { paymentIntentId: 'pi_123' });
// Type error: pending → shipped (must go through paid first)
transitionOrder(pendingOrder, 'shipped', { trackingNumber: '...' });
// ^^^^^^^^^
// Type '"shipped"' is not assignable to type '"paid" | "cancelled"'
// Type error: delivered → anything (terminal state)
transitionOrder(deliveredOrder, 'cancelled', { reason: '...' });
// ^^^^^^^^^^^
// Type '"cancelled"' is not assignable to type 'never'
Pattern 2: Branded Types for Domain Primitives
The Problem: Primitive Obsession
// Everything is a string
function getUser(userId: string): Promise<User>;
function getOrder(orderId: string): Promise<Order>;
function chargeCustomer(customerId: string, amount: number): Promise<void>;
// These compile but are bugs:
getUser(orderId); // Passed order ID where user ID expected
chargeCustomer(userId, -50); // Negative amount, wrong ID type
// Especially dangerous in functions with multiple string params:
async function transferFunds(
fromAccountId: string,
toAccountId: string,
amount: number
): Promise<void>;
// Did you swap from and to? The compiler doesn't know.
await transferFunds(destinationAccount, sourceAccount, 1000);
The Solution: Branded Types
// Create a unique brand for each domain concept
declare const __brand: unique symbol;
type Brand<T, B> = T & { [__brand]: B };
// Domain-specific ID types
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type AccountId = Brand<string, 'AccountId'>;
type CustomerId = Brand<string, 'CustomerId'>;
// Domain-specific value types
type Email = Brand<string, 'Email'>;
type Money = Brand<number, 'Money'>; // Cents, not dollars
type Percentage = Brand<number, 'Percentage'>; // 0-100
// Constructor functions that validate at boundaries
function UserId(value: string): UserId {
if (!value.startsWith('usr_')) {
throw new Error(`Invalid user ID format: ${value}`);
}
return value as UserId;
}
function Money(cents: number): Money {
if (!Number.isInteger(cents) || cents < 0) {
throw new Error(`Invalid money amount: ${cents}`);
}
return cents as Money;
}
function Email(value: string): Email {
if (!EMAIL_REGEX.test(value)) {
throw new Error(`Invalid email: ${value}`);
}
return value as Email;
}
Now functions declare exactly what they accept:
function getUser(userId: UserId): Promise<User>;
function getOrder(orderId: OrderId): Promise<Order>;
function chargeCustomer(customerId: CustomerId, amount: Money): Promise<void>;
// Type errors — caught at compile time
getUser(orderId);
// ^^^^^^^
// Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
chargeCustomer(userId, Money(-50));
// ^^^^^^
// Argument of type 'UserId' is not assignable to parameter of type 'CustomerId'.
// Correct usage
const userId = UserId('usr_abc123'); // Validated at creation
const amount = Money(5000); // 5000 cents = $50.00
await chargeCustomer(customerId, amount); // Types guarantee correctness
Transfer Function — Now Type-Safe
async function transferFunds(
from: AccountId,
to: AccountId,
amount: Money
): Promise<TransferResult>;
// Parameter order is enforced by labels at call site (by convention)
// But even without labels, you can't pass a UserId where AccountId is expected
await transferFunds(
fromAccountId, // AccountId
toAccountId, // AccountId
amount // Money
);
// Type error: wrong ID type
await transferFunds(userId, accountId, amount);
// ^^^^^^
// Argument of type 'UserId' is not assignable to parameter of type 'AccountId'.
Validated Primitives with Refinement
// More sophisticated: validated branded types
type NonEmptyString = Brand<string, 'NonEmptyString'>;
type PositiveInteger = Brand<number, 'PositiveInteger'>;
type Latitude = Brand<number, 'Latitude'>; // -90 to 90
type Longitude = Brand<number, 'Longitude'>; // -180 to 180
// Result type for validation
type ValidationResult<T> =
| { success: true; value: T }
| { success: false; error: string };
function NonEmptyString(value: string): ValidationResult<NonEmptyString> {
if (value.trim().length === 0) {
return { success: false, error: 'String cannot be empty' };
}
return { success: true, value: value as NonEmptyString };
}
function Latitude(value: number): ValidationResult<Latitude> {
if (value < -90 || value > 90) {
return { success: false, error: `Latitude must be between -90 and 90, got ${value}` };
}
return { success: true, value: value as Latitude };
}
// Usage at system boundaries
function parseCoordinatesFromApi(data: unknown): ValidationResult<Coordinates> {
const parsed = coordinatesSchema.safeParse(data);
if (!parsed.success) {
return { success: false, error: parsed.error.message };
}
const lat = Latitude(parsed.data.lat);
if (!lat.success) return lat;
const lng = Longitude(parsed.data.lng);
if (!lng.success) return lng;
return {
success: true,
value: { lat: lat.value, lng: lng.value },
};
}
// Interior functions don't need validation — types guarantee correctness
function calculateDistance(from: Coordinates, to: Coordinates): Kilometers {
// lat and lng are guaranteed to be valid — no runtime checks needed
const φ1 = (from.lat * Math.PI) / 180;
const φ2 = (to.lat * Math.PI) / 180;
// ... haversine formula
}
Pattern 3: Template Literal Types for String Constraints
The Problem: Stringly-Typed APIs
// Typical event emitter
interface EventEmitter {
on(event: string, handler: Function): void;
emit(event: string, data: any): void;
}
emitter.on('user:created', handleUserCreated);
emitter.on('user:creaetd', handleUserCreated); // Typo — runtime bug
emitter.emit('order:shipped', userData); // Wrong data type — runtime bug
// Typical API client
interface ApiClient {
request(method: string, path: string, data?: any): Promise<any>;
}
api.request('POTS', '/users', userData); // Typo in method
api.request('GET', '/usres'); // Typo in path
The Solution: Template Literal Types
// Define valid event patterns
type DomainEntity = 'user' | 'order' | 'product' | 'payment';
type EventAction = 'created' | 'updated' | 'deleted' | 'archived';
type DomainEvent = `${DomainEntity}:${EventAction}`;
// DomainEvent =
// | 'user:created' | 'user:updated' | 'user:deleted' | 'user:archived'
// | 'order:created' | 'order:updated' | 'order:deleted' | 'order:archived'
// | 'product:created' | ...
// Type-safe event emitter
interface TypedEventEmitter<Events extends Record<string, unknown>> {
on<E extends keyof Events>(
event: E,
handler: (data: Events[E]) => void
): void;
emit<E extends keyof Events>(event: E, data: Events[E]): void;
}
// Define event payloads
interface AppEvents {
'user:created': { userId: UserId; email: Email };
'user:updated': { userId: UserId; changes: Partial<User> };
'user:deleted': { userId: UserId; deletedAt: Date };
'order:created': { orderId: OrderId; userId: UserId; items: OrderItem[] };
'order:shipped': { orderId: OrderId; trackingNumber: string };
}
const emitter: TypedEventEmitter<AppEvents> = createEmitter();
// Type-safe — correct event name and payload
emitter.on('user:created', ({ userId, email }) => {
console.log(`User ${userId} created with email ${email}`);
});
// Type error: invalid event name
emitter.on('user:creaetd', handler);
// ^^^^^^^^^^^^^
// Argument of type '"user:creaetd"' is not assignable to type 'keyof AppEvents'.
// Type error: wrong payload type
emitter.emit('order:shipped', { userId: user.id });
// ^^^^^^^^^^^^^^^^^^^
// Property 'trackingNumber' is missing in type '{ userId: UserId; }'
API Routes with Template Literals
// Define API route patterns
type ApiVersion = 'v1' | 'v2';
type ResourcePath = '/users' | '/orders' | '/products';
type ResourceWithId = `${ResourcePath}/:id`;
type ApiPath = ResourcePath | ResourceWithId;
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
// Route definitions
type RouteDefinitions = {
'GET /v1/users': { response: User[] };
'POST /v1/users': { body: CreateUserInput; response: User };
'GET /v1/users/:id': { params: { id: UserId }; response: User };
'PUT /v1/users/:id': { params: { id: UserId }; body: UpdateUserInput; response: User };
'DELETE /v1/users/:id': { params: { id: UserId }; response: void };
'GET /v1/orders': { response: Order[]; query: { status?: Order['kind'] } };
'POST /v1/orders': { body: CreateOrderInput; response: Order };
};
// Type-safe API client
type RouteKey = keyof RouteDefinitions;
type ExtractMethod<R extends RouteKey> = R extends `${infer M} ${string}` ? M : never;
type ExtractPath<R extends RouteKey> = R extends `${string} ${infer P}` ? P : never;
interface TypedApiClient {
request<R extends RouteKey>(
route: R,
options: RequestOptions<RouteDefinitions[R]>
): Promise<RouteDefinitions[R]['response']>;
}
// Usage
const api: TypedApiClient = createApiClient();
// Type-safe — method, path, body, and response are all checked
const user = await api.request('POST /v1/users', {
body: { email: Email('user@example.com'), name: 'John' },
});
// user is typed as User
// Type error: invalid route
await api.request('POST /v1/userz', { body: userData });
// ^^^^^^^^^^^^^^^^^
// Argument of type '"POST /v1/userz"' is not assignable to type 'RouteKey'.
// Type error: wrong body type for route
await api.request('GET /v1/users/:id', { body: userData });
// ^^^^^^^^^^^^^^^^
// Object literal may only specify known properties, and 'body' does not exist.
Environment Variable Types
// Define required environment variables
type EnvVar =
| `DATABASE_URL`
| `REDIS_URL`
| `API_KEY`
| `AWS_${string}` // Any AWS_* variable
| `FEATURE_${Uppercase<string>}` // FEATURE_* in uppercase
// Environment with typed access
type TypedEnv = {
DATABASE_URL: string;
REDIS_URL: string;
API_KEY: string;
NODE_ENV: 'development' | 'production' | 'test';
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
} & {
[K in `AWS_${string}`]?: string;
} & {
[K in `FEATURE_${string}`]?: 'true' | 'false';
};
// Strict env getter
function getEnv<K extends keyof TypedEnv>(
key: K
): K extends keyof RequiredEnv ? TypedEnv[K] : TypedEnv[K] | undefined {
const value = process.env[key];
if (isRequiredEnvKey(key) && value === undefined) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value as any;
}
// Type-safe access
const dbUrl = getEnv('DATABASE_URL'); // string (required)
const awsKey = getEnv('AWS_SECRET_KEY'); // string | undefined (optional)
// Type error
const bad = getEnv('DATABSE_URL');
// ^^^^^^^^^^^^^
// Argument of type '"DATABSE_URL"' is not assignable.
Pattern 4: The infer Keyword for Type-Level Programming
Understanding infer
infer lets you extract types from other types — pattern matching at the type level.
// Basic: extract return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type A = ReturnType<() => string>; // string
type B = ReturnType<(x: number) => User>; // User
// Extract promise resolution type
type Awaited<T> = T extends Promise<infer U> ? U : T;
type C = Awaited<Promise<User>>; // User
type D = Awaited<Promise<Promise<User>>>; // Promise<User> (only unwraps one level)
// Recursive unwrap
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type E = DeepAwaited<Promise<Promise<User>>>; // User
// Extract array element type
type ElementType<T> = T extends (infer E)[] ? E : never;
type F = ElementType<User[]>; // User
type G = ElementType<string[]>; // string
Practical: Type-Safe Event Handlers from Event Map
// Given an event map, generate handler types
type EventMap = {
'user:created': { userId: string; email: string };
'order:placed': { orderId: string; total: number };
'payment:failed': { orderId: string; error: string };
};
// Extract the data type for a specific event
type EventData<E extends keyof EventMap> = EventMap[E];
// Generate handler type from event name
type EventHandler<E extends keyof EventMap> = (data: EventData<E>) => void;
// Generate all handlers type
type EventHandlers = {
[E in keyof EventMap as `on${Capitalize<E>}`]: EventHandler<E>;
};
// EventHandlers = {
// onUser:created: (data: { userId: string; email: string }) => void;
// onOrder:placed: (data: { orderId: string; total: number }) => void;
// onPayment:failed: (data: { orderId: string; error: string }) => void;
// }
Extract Route Parameters
// Extract path parameters from route string
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type A = ExtractRouteParams<'/users/:userId'>;
// 'userId'
type B = ExtractRouteParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'
type C = ExtractRouteParams<'/users/:userId/posts/:postId/comments/:commentId'>;
// 'userId' | 'postId' | 'commentId'
// Build params object type
type RouteParams<T extends string> = {
[K in ExtractRouteParams<T>]: string;
};
type Params = RouteParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }
// Type-safe route handler
function createRoute<T extends string>(
path: T,
handler: (params: RouteParams<T>) => Response
): Route<T> {
return { path, handler };
}
// Usage
createRoute('/users/:userId/posts/:postId', (params) => {
// params is typed as { userId: string; postId: string }
const { userId, postId } = params;
return fetchPost(userId, postId);
});
// Type error: accessing non-existent param
createRoute('/users/:userId', (params) => {
params.postId; // Error: Property 'postId' does not exist
});
Parse JSON Schema to TypeScript
// Map JSON Schema types to TypeScript types
type JsonSchemaToTs<T> =
T extends { type: 'string' } ? string :
T extends { type: 'number' } ? number :
T extends { type: 'boolean' } ? boolean :
T extends { type: 'null' } ? null :
T extends { type: 'array'; items: infer Items } ? JsonSchemaToTs<Items>[] :
T extends { type: 'object'; properties: infer Props } ?
{ [K in keyof Props]: JsonSchemaToTs<Props[K]> } :
never;
// Example schema
const userSchema = {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
age: { type: 'number' },
isActive: { type: 'boolean' },
tags: { type: 'array', items: { type: 'string' } },
},
} as const;
type UserFromSchema = JsonSchemaToTs<typeof userSchema>;
// {
// id: string;
// email: string;
// age: number;
// isActive: boolean;
// tags: string[];
// }
Deep Partial with Infer
// Standard Partial only works one level deep
type Partial<T> = { [K in keyof T]?: T[K] };
// Deep partial recurses into nested objects
type DeepPartial<T> =
T extends Function ? T :
T extends Array<infer U> ? Array<DeepPartial<U>> :
T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } :
T;
interface Config {
server: {
host: string;
port: number;
ssl: {
enabled: boolean;
cert: string;
key: string;
};
};
database: {
url: string;
pool: {
min: number;
max: number;
};
};
}
// Can partially override at any depth
type ConfigOverride = DeepPartial<Config>;
const override: ConfigOverride = {
server: {
ssl: {
enabled: true,
// cert and key are optional here
},
},
// database is optional
};
Pattern 5: Conditional Types for Business Rules
Encoding Business Logic in Types
// Business rule: Only verified sellers can list products over $10,000
interface Seller {
id: SellerId;
verified: boolean;
verifiedAt?: Date;
}
interface Product {
id: ProductId;
price: Money;
sellerId: SellerId;
}
// Encode the rule in types
type HighValueProduct = Product & { price: Brand<number, 'HighValue'> };
type CanListHighValueProduct<S extends Seller> =
S['verified'] extends true ? true : false;
// Type-safe listing function using overloads
function listProduct(seller: Seller & { verified: true }, product: Product): ListedProduct;
function listProduct(seller: Seller & { verified: false }, product: Product & { price: LowValuePrice }): ListedProduct;
function listProduct(seller: Seller, product: Product): ListedProduct {
// Implementation
}
// Verified seller can list any product
const verifiedSeller: Seller & { verified: true } = getVerifiedSeller();
listProduct(verifiedSeller, expensiveProduct); // OK
// Unverified seller can only list low-value products
const unverifiedSeller: Seller & { verified: false } = getUnverifiedSeller();
listProduct(unverifiedSeller, cheapProduct); // OK
listProduct(unverifiedSeller, expensiveProduct); // Type error!
Permission-Based API Access
// User roles
type Role = 'guest' | 'user' | 'admin' | 'superadmin';
// Permission mapping
type RolePermissions = {
guest: 'read:public';
user: 'read:public' | 'read:own' | 'write:own';
admin: 'read:public' | 'read:own' | 'write:own' | 'read:all' | 'write:all';
superadmin: 'read:public' | 'read:own' | 'write:own' | 'read:all' | 'write:all' | 'delete:all' | 'manage:users';
};
// Extract permissions for a role
type PermissionsFor<R extends Role> = RolePermissions[R];
// Check if role has permission
type HasPermission<R extends Role, P extends string> =
P extends PermissionsFor<R> ? true : false;
// Type-safe API with permission requirements
interface ApiEndpoint<RequiredPermission extends string> {
permission: RequiredPermission;
handler: (req: Request) => Response;
}
function callEndpoint<R extends Role, P extends string>(
user: { role: R },
endpoint: ApiEndpoint<P>
): HasPermission<R, P> extends true ? Promise<Response> : never {
// Implementation checks at runtime, but types prevent misuse
return endpoint.handler(createRequest()) as any;
}
// Define endpoints
const deleteUserEndpoint: ApiEndpoint<'delete:all'> = {
permission: 'delete:all',
handler: (req) => deleteUser(req),
};
const readPublicEndpoint: ApiEndpoint<'read:public'> = {
permission: 'read:public',
handler: (req) => getPublicData(req),
};
// Type-safe access
const admin: { role: 'admin' } = getAdmin();
const superadmin: { role: 'superadmin' } = getSuperadmin();
callEndpoint(admin, readPublicEndpoint); // OK
callEndpoint(admin, deleteUserEndpoint); // Type error: returns never
callEndpoint(superadmin, deleteUserEndpoint); // OK
Pattern 6: Builder Pattern with Type Accumulation
Type-Safe Query Builder
// Track what's been set in the type
interface QueryState {
hasSelect: boolean;
hasFrom: boolean;
hasWhere: boolean;
hasOrderBy: boolean;
}
interface QueryBuilder<
T,
State extends QueryState = {
hasSelect: false;
hasFrom: false;
hasWhere: false;
hasOrderBy: false;
}
> {
select<K extends keyof T>(
...columns: K[]
): QueryBuilder<Pick<T, K>, State & { hasSelect: true }>;
from(
table: string
): QueryBuilder<T, State & { hasFrom: true }>;
where(
condition: WhereCondition<T>
): QueryBuilder<T, State & { hasWhere: true }>;
orderBy<K extends keyof T>(
column: K,
direction: 'asc' | 'desc'
): QueryBuilder<T, State & { hasOrderBy: true }>;
// Can only execute if required fields are set
execute(
this: QueryBuilder<T, State & { hasSelect: true; hasFrom: true }>
): Promise<T[]>;
}
// Usage
const query = createQueryBuilder<User>()
.select('id', 'email', 'name')
.from('users')
.where({ isActive: true })
.orderBy('createdAt', 'desc');
await query.execute(); // OK — has select and from
// Type error: missing required clauses
createQueryBuilder<User>()
.select('id', 'email')
.execute();
// Error: The 'this' context of type 'QueryBuilder<..., { hasSelect: true; hasFrom: false; ... }>'
// is not assignable to method's 'this' of type 'QueryBuilder<..., { hasSelect: true; hasFrom: true; ... }>'
Form Builder with Validation State
// Track validation state in types
interface FieldState {
name: string;
validated: boolean;
}
type FormState<Fields extends Record<string, FieldState>> = {
fields: Fields;
allValidated: Fields[keyof Fields]['validated'] extends true ? true : false;
};
interface FormBuilder<State extends Record<string, FieldState>> {
addField<N extends string>(
name: N,
config: FieldConfig
): FormBuilder<State & { [K in N]: { name: K; validated: false } }>;
validate<N extends keyof State>(
name: N
): FormBuilder<{
[K in keyof State]: K extends N
? { name: State[K]['name']; validated: true }
: State[K];
}>;
// Can only submit if all fields are validated
submit<S extends State>(
this: FormBuilder<S> & {
[K in keyof S]: S[K]['validated'] extends true ? unknown : never;
}
): Promise<FormData>;
}
// Usage
const form = createFormBuilder()
.addField('email', { type: 'email', required: true })
.addField('password', { type: 'password', minLength: 8 })
.validate('email')
.validate('password');
await form.submit(); // OK — all fields validated
// Type error: not all fields validated
createFormBuilder()
.addField('email', { type: 'email' })
.addField('password', { type: 'password' })
.validate('email')
.submit(); // Error: password not validated
Pattern 7: Phantom Types for State Tracking
Resource Lifecycle Management
// Phantom type parameter — exists only at type level
declare const __state: unique symbol;
type State<S extends string> = { [__state]: S };
// Database connection states
type Disconnected = State<'disconnected'>;
type Connected = State<'connected'>;
type InTransaction = State<'in_transaction'>;
// Connection with state tracking
interface Connection<S> {
// Phantom type parameter S tracks state
__phantom?: S;
}
// Operations only available in certain states
function connect(
conn: Connection<Disconnected>
): Connection<Connected> {
// ... establish connection
return conn as Connection<Connected>;
}
function disconnect(
conn: Connection<Connected>
): Connection<Disconnected> {
// ... close connection
return conn as Connection<Disconnected>;
}
function beginTransaction(
conn: Connection<Connected>
): Connection<InTransaction> {
// ... start transaction
return conn as Connection<InTransaction>;
}
function commit(
conn: Connection<InTransaction>
): Connection<Connected> {
// ... commit transaction
return conn as Connection<Connected>;
}
function rollback(
conn: Connection<InTransaction>
): Connection<Connected> {
// ... rollback transaction
return conn as Connection<Connected>;
}
function query<T>(
conn: Connection<Connected> | Connection<InTransaction>,
sql: string
): Promise<T[]> {
// ... execute query
}
// Usage — state transitions enforced at compile time
let conn: Connection<Disconnected> = createConnection();
// Type error: can't query disconnected connection
query(conn, 'SELECT * FROM users');
// Error: Argument of type 'Connection<Disconnected>' is not assignable.
conn = connect(conn); // Now Connected
query(conn, 'SELECT 1'); // OK
conn = beginTransaction(conn); // Now InTransaction
query(conn, 'INSERT INTO...'); // OK
// Type error: can't disconnect while in transaction
disconnect(conn);
// Error: Connection<InTransaction> not assignable to Connection<Connected>
conn = commit(conn); // Back to Connected
conn = disconnect(conn); // Now Disconnected — OK
File Handle States
type Closed = State<'closed'>;
type OpenRead = State<'open_read'>;
type OpenWrite = State<'open_write'>;
type OpenReadWrite = State<'open_readwrite'>;
interface FileHandle<S> {
path: string;
__state?: S;
}
function openRead(path: string): FileHandle<OpenRead>;
function openWrite(path: string): FileHandle<OpenWrite>;
function openReadWrite(path: string): FileHandle<OpenReadWrite>;
function read(
handle: FileHandle<OpenRead> | FileHandle<OpenReadWrite>
): Promise<Buffer>;
function write(
handle: FileHandle<OpenWrite> | FileHandle<OpenReadWrite>,
data: Buffer
): Promise<void>;
function close<S>(handle: FileHandle<S>): FileHandle<Closed>;
// Usage
const readHandle = openRead('/tmp/data.txt');
await read(readHandle); // OK
await write(readHandle, b); // Type error: can't write to read-only handle
const writeHandle = openWrite('/tmp/out.txt');
await write(writeHandle, b); // OK
await read(writeHandle); // Type error: can't read from write-only handle
const rwHandle = openReadWrite('/tmp/file.txt');
await read(rwHandle); // OK
await write(rwHandle, b); // OK
Putting It All Together: A Domain Model
// ═══════════════════════════════════════════════════════════════════════════
// DOMAIN PRIMITIVES
// ═══════════════════════════════════════════════════════════════════════════
declare const __brand: unique symbol;
type Brand<T, B> = T & { [__brand]: B };
// IDs
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type ProductId = Brand<string, 'ProductId'>;
// Values
type Email = Brand<string, 'Email'>;
type Money = Brand<number, 'Money'>;
type Quantity = Brand<number, 'Quantity'>;
// Constructors with validation
const UserId = (s: string): UserId => {
if (!s.match(/^usr_[a-z0-9]{16}$/)) throw new Error('Invalid UserId');
return s as UserId;
};
const Money = (cents: number): Money => {
if (!Number.isInteger(cents) || cents < 0) throw new Error('Invalid Money');
return cents as Money;
};
const Quantity = (n: number): Quantity => {
if (!Number.isInteger(n) || n < 1) throw new Error('Invalid Quantity');
return n as Quantity;
};
// ═══════════════════════════════════════════════════════════════════════════
// ORDER STATE MACHINE
// ═══════════════════════════════════════════════════════════════════════════
interface OrderItem {
productId: ProductId;
quantity: Quantity;
unitPrice: Money;
}
interface BaseOrder {
id: OrderId;
userId: UserId;
items: OrderItem[];
createdAt: Date;
}
interface DraftOrder extends BaseOrder {
kind: 'draft';
}
interface SubmittedOrder extends BaseOrder {
kind: 'submitted';
submittedAt: Date;
}
interface PaidOrder extends BaseOrder {
kind: 'paid';
submittedAt: Date;
paidAt: Date;
paymentId: string;
}
interface ShippedOrder extends BaseOrder {
kind: 'shipped';
submittedAt: Date;
paidAt: Date;
paymentId: string;
shippedAt: Date;
trackingNumber: string;
}
interface DeliveredOrder extends BaseOrder {
kind: 'delivered';
submittedAt: Date;
paidAt: Date;
paymentId: string;
shippedAt: Date;
trackingNumber: string;
deliveredAt: Date;
}
interface CancelledOrder extends BaseOrder {
kind: 'cancelled';
cancelledAt: Date;
reason: string;
refundId?: string;
}
type Order =
| DraftOrder
| SubmittedOrder
| PaidOrder
| ShippedOrder
| DeliveredOrder
| CancelledOrder;
// ═══════════════════════════════════════════════════════════════════════════
// STATE TRANSITIONS
// ═══════════════════════════════════════════════════════════════════════════
// Valid transitions encoded in types
function submitOrder(order: DraftOrder): SubmittedOrder {
return {
...order,
kind: 'submitted',
submittedAt: new Date(),
};
}
function payOrder(order: SubmittedOrder, paymentId: string): PaidOrder {
return {
...order,
kind: 'paid',
paidAt: new Date(),
paymentId,
};
}
function shipOrder(order: PaidOrder, trackingNumber: string): ShippedOrder {
return {
...order,
kind: 'shipped',
shippedAt: new Date(),
trackingNumber,
};
}
function deliverOrder(order: ShippedOrder): DeliveredOrder {
return {
...order,
kind: 'delivered',
deliveredAt: new Date(),
};
}
// Cancel has special rules — can cancel from multiple states
function cancelOrder<O extends DraftOrder | SubmittedOrder | PaidOrder>(
order: O,
reason: string
): CancelledOrder {
const cancelled: CancelledOrder = {
id: order.id,
userId: order.userId,
items: order.items,
createdAt: order.createdAt,
kind: 'cancelled',
cancelledAt: new Date(),
reason,
};
// If paid, initiate refund
if (order.kind === 'paid') {
cancelled.refundId = initiateRefund(order.paymentId);
}
return cancelled;
}
// ═══════════════════════════════════════════════════════════════════════════
// TYPE-SAFE OPERATIONS
// ═══════════════════════════════════════════════════════════════════════════
// Calculate total — works on any order state
function calculateTotal(order: Order): Money {
const cents = order.items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0
);
return Money(cents);
}
// Get tracking — only available for shipped/delivered orders
function getTracking(order: ShippedOrder | DeliveredOrder): string {
return order.trackingNumber;
}
// Type-safe event handlers
type OrderEvent =
| { type: 'order:submitted'; data: SubmittedOrder }
| { type: 'order:paid'; data: PaidOrder }
| { type: 'order:shipped'; data: ShippedOrder }
| { type: 'order:delivered'; data: DeliveredOrder }
| { type: 'order:cancelled'; data: CancelledOrder };
function handleOrderEvent(event: OrderEvent): void {
switch (event.type) {
case 'order:submitted':
// event.data is SubmittedOrder
sendConfirmationEmail(event.data.userId);
break;
case 'order:paid':
// event.data is PaidOrder
notifyFulfillment(event.data);
break;
case 'order:shipped':
// event.data is ShippedOrder — has trackingNumber
sendShippingNotification(event.data.userId, event.data.trackingNumber);
break;
case 'order:delivered':
// event.data is DeliveredOrder
requestReview(event.data.userId, event.data.id);
break;
case 'order:cancelled':
// event.data is CancelledOrder
if (event.data.refundId) {
sendRefundConfirmation(event.data.userId, event.data.refundId);
}
break;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// COMPILE-TIME GUARANTEES
// ═══════════════════════════════════════════════════════════════════════════
// All of these are type errors:
// Can't ship unpaid order
shipOrder(submittedOrder, 'TRACK123');
// Error: Argument of type 'SubmittedOrder' is not assignable to 'PaidOrder'
// Can't cancel shipped order
cancelOrder(shippedOrder, 'Customer request');
// Error: Argument of type 'ShippedOrder' is not assignable to 'DraftOrder | SubmittedOrder | PaidOrder'
// Can't access tracking on unpaid order
getTracking(paidOrder);
// Error: Argument of type 'PaidOrder' is not assignable to 'ShippedOrder | DeliveredOrder'
// Can't submit already-submitted order
submitOrder(submittedOrder);
// Error: Argument of type 'SubmittedOrder' is not assignable to 'DraftOrder'
The Payoff
When domain rules are encoded in types:
┌─────────────────────────────────────────────────────────────────────────────┐
│ BUGS CAUGHT AT COMPILE TIME │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ✓ Invalid state transitions "Shipped an unpaid order" │
│ ✓ ID type mismatches "Passed userId where orderId expected" │
│ ✓ Invalid business operations "Cancelled already-delivered order" │
│ ✓ Missing required data "Shipped without tracking number" │
│ ✓ Impossible states "Order both cancelled and delivered" │
│ ✓ Exhaustiveness gaps "Forgot to handle cancelled state" │
│ ✓ Permission violations "Guest tried admin operation" │
│ ✓ Resource state violations "Queried disconnected database" │
│ │
│ RESULT: Fewer runtime errors, fewer tests needed, self-documenting code │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The type system becomes your first line of defense. Tests become smaller and more focused — you're testing business logic, not checking "what if someone passes the wrong ID type."
When Not to Use These Patterns
- Prototyping: Adds friction when you're still discovering the domain
- Simple CRUD: Overkill for basic data in/out operations
- External API boundaries: You'll need runtime validation anyway (Zod, etc.)
- Team unfamiliarity: Advanced types can be write-once-read-never without shared understanding
Start with branded types and discriminated unions — they have the best effort-to-value ratio. Add conditional types and template literals as complexity grows.
The goal isn't to show off TypeScript tricks. It's to make invalid states unrepresentable so the compiler catches domain violations before runtime ever sees them.
What did you think?