load {
id: string;
merchant: string;
amount: number;
currency: string;
metadata: Record<string, unknown>;
}
type ProcessablePayment = Omit<RawPaymentPayload, 'id' | 'metadata'> & {
transactionId: TransactionId;
merchantId: MerchantId;
};
**Rationale:** `Omit` removes unsafe structural fields, while branded types prevent accidental assignment between `TransactionId` and `MerchantId` even though both are strings at runtime. Factory functions enforce validation at creation, guaranteeing that any value of type `TransactionId` has already passed format checks. This eliminates defensive `if (!isValidId())` checks throughout the application.
### Step 2: Declarative Routing & Event Mapping
Template literal types and key remapping allow you to generate type-safe event handlers and API routes without manual mapping objects.
```typescript
type PaymentEvent = 'initiated' | 'authorized' | 'settled' | 'failed';
type EventPayload<E extends PaymentEvent> =
E extends 'initiated' ? { amount: number; currency: string } :
E extends 'authorized' ? { transactionId: TransactionId; authCode: string } :
E extends 'settled' ? { transactionId: TransactionId; settlementId: string } :
{ transactionId: TransactionId; reason: string };
type EventHandlerMap = {
[K in PaymentEvent as `handle${Capitalize<K>}`]: (payload: EventPayload<K>) => void;
};
const handlers: EventHandlerMap = {
handleInitiated: (p) => console.log(`Starting ${p.amount} ${p.currency}`),
handleAuthorized: (p) => console.log(`Auth ${p.authCode} for ${p.transactionId}`),
handleSettled: (p) => console.log(`Settled ${p.settlementId}`),
handleFailed: (p) => console.error(`Failed: ${p.reason}`),
};
Rationale: The as clause in mapped types remaps keys dynamically. Combined with Capitalize, it generates method names automatically. EventPayload uses conditional types to map each event string to its exact payload shape. The compiler guarantees that handleAuthorized only receives authorized payloads, and adding a new event forces implementation of its corresponding handler. This pattern replaces sprawling switch statements or registry objects with self-documenting, compiler-enforced contracts.
Step 3: Deterministic State Transitions
Discriminated unions model finite state machines with mathematical precision. Every possible state is explicit, and transitions are validated by the compiler.
type SyncPhase = 'pending' | 'syncing' | 'completed' | 'errored';
type SyncMachine<T> =
| { phase: 'pending'; retries: number }
| { phase: 'syncing'; progress: number; currentBatch: T[] }
| { phase: 'completed'; totalRecords: number; durationMs: number }
| { phase: 'errored'; lastError: Error; failedBatch: T[] };
function advanceSync<T>(state: SyncMachine<T>, action: 'start' | 'progress' | 'finish' | 'fail'): SyncMachine<T> {
switch (state.phase) {
case 'pending':
if (action === 'start') return { phase: 'syncing', progress: 0, currentBatch: [] };
break;
case 'syncing':
if (action === 'progress') return { ...state, progress: state.progress + 10 };
if (action === 'finish') return { phase: 'completed', totalRecords: 150, durationMs: 1200 };
if (action === 'fail') return { phase: 'errored', lastError: new Error('Network timeout'), failedBatch: state.currentBatch };
break;
case 'completed':
case 'errored':
return state;
}
return state;
}
// Exhaustiveness guard
function assertExhaustive(value: never): never {
throw new Error(`Unhandled sync phase: ${value}`);
}
function logState<T>(state: SyncMachine<T>) {
switch (state.phase) {
case 'pending': console.log('Waiting...'); break;
case 'syncing': console.log(`Syncing: ${state.progress}%`); break;
case 'completed': console.log(`Done in ${state.durationMs}ms`); break;
case 'errored': console.error(`Failed: ${state.lastError.message}`); break;
default: assertExhaustive(state);
}
}
Rationale: The phase property acts as a discriminant. TypeScript narrows the union automatically inside each case, making state.progress and state.durationMs type-safe without casting. The assertExhaustive function leverages the never type to catch missing cases at compile time. If a new phase is added to SyncMachine, the compiler will flag every switch statement that lacks a handler, preventing silent state leaks in production.
Step 4: Functional Error Boundaries & Structural Validation
Traditional try/catch blocks scatter error handling logic. A Result type centralizes it, while satisfies and as const lock down configuration shapes without losing literal inference.
type AppError = { code: string; message: string; recoverable: boolean };
type Outcome<T> =
| { status: 'ok'; data: T }
| { status: 'err'; error: AppError };
async function processPayment(payload: ProcessablePayment): Promise<Outcome<TransactionId>> {
try {
const response = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify(payload),
});
if (!response.ok) {
return {
status: 'err',
error: { code: 'HTTP_FAIL', message: response.statusText, recoverable: true },
};
}
const result = await response.json() as { id: string };
return { status: 'ok', data: createTransactionId(result.id) };
} catch (cause) {
return {
status: 'err',
error: { code: 'NETWORK', message: cause instanceof Error ? cause.message : 'Unknown', recoverable: false },
};
}
}
// Configuration validation without widening
interface GatewayConfig {
endpoint: string;
timeout: number;
retries: number;
tls: boolean;
}
const productionConfig = {
endpoint: 'https://pay.example.com/v2',
timeout: 5000,
retries: 3,
tls: true,
} satisfies GatewayConfig;
// Route locking
const ROUTE_MAP = {
INIT: '/payments/init',
VERIFY: '/payments/verify',
SETTLE: '/payments/settle',
} as const;
type RouteKey = keyof typeof ROUTE_MAP;
type RoutePath = typeof ROUTE_MAP[RouteKey];
Rationale: Outcome<T> replaces exceptions with explicit return values, making error paths visible in function signatures. Callers must handle both branches, eliminating uncaught promise rejections. satisfies validates that productionConfig matches GatewayConfig structurally without widening endpoint to string. This preserves literal types for downstream routing. as const freezes ROUTE_MAP, enabling type-safe navigation where invalid routes are caught immediately.
Pitfall Guide
1. The satisfies Misplacement
Explanation: Developers often confuse satisfies with type annotation (:). Using : widens literal types to their base primitives, breaking downstream template literals or discriminated unions.
Fix: Use satisfies when you want to validate structure while preserving literal inference. Use : when you explicitly want to widen or enforce a base type.
2. Recursive Type Stack Overflow
Explanation: Conditional types that recurse without a base case or depth limit will trigger Type instantiation is excessively deep and possibly infinite errors. This commonly happens in DeepPartial or path extraction utilities.
Fix: Add a depth counter or conditional guard. Example: type DeepPartial<T, D extends number = 5> = D extends 0 ? T : T extends object ? { [K in keyof T]?: DeepPartial<T[K], Prev[D]> } : T;
3. Exhaustiveness Check Bypass
Explanation: Omitting the default: assertExhaustive(state) case in discriminated unions allows new states to be added without updating handlers. The compiler silently accepts missing branches.
Fix: Always pair discriminated unions with a never-based exhaustiveness guard. Enable strictNullChecks and noImplicitReturns to catch unhandled paths.
4. Brand Leakage
Explanation: Branded types are erased at runtime. If you cast raw strings directly (raw as TransactionId) without validation, you bypass the nominal safety guarantee.
Fix: Encapsulate brand creation in factory functions that perform runtime validation. Export only the type, not the cast. Use readonly __brand to prevent accidental mutation.
5. Template Literal Combinatorial Explosion
Explanation: Interpolating multiple template literals or using string wildcards in mapped types can generate millions of union members, crashing the compiler or slowing IDE responsiveness.
Fix: Constrain template literals to finite unions. Avoid string wildcards in production routing. Use as const arrays and keyof typeof to keep union sizes predictable.
6. Ignoring Awaited in Async Chains
Explanation: Nested promises (Promise<Promise<T>>) or middleware chains that wrap responses often require manual .then() unwrapping. Forgetting Awaited leads to incorrect return type inference.
Fix: Use Awaited<T> to recursively unwrap promise layers. Apply it to generic return types in async utilities: type Unwrapped<T> = Awaited<T>;
7. Over-Abstracting Utility Types
Explanation: Chaining Pick, Omit, Partial, and Required excessively creates opaque types that are difficult to debug. IDE tooltips become unreadable, and refactoring breaks silently.
Fix: Compose utility types only when necessary. Prefer explicit interfaces for public APIs. Use type aliases sparingly and document their intent. Keep the type graph shallow.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Configuration validation | satisfies + as const | Preserves literal types while enforcing structure | Low design time, zero runtime cost |
| Cross-domain ID safety | Branded types + factories | Prevents accidental assignment between structurally identical strings | Medium upfront effort, high bug reduction |
| Complex state transitions | Discriminated unions + never guards | Guarantees exhaustive handling and eliminates invalid state combinations | High initial modeling, drastically lower maintenance |
| Event routing & handlers | Template literals + key remapping | Auto-generates type-safe handler maps without manual registration | Low runtime overhead, improves IDE autocomplete |
| Async error boundaries | Outcome<T> union | Makes error paths explicit in function signatures | Eliminates uncaught rejections, simplifies testing |
Configuration Template
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// src/types/base.ts
export type Brand<T, Marker> = T & { readonly __brand: unique symbol & Marker };
export type Outcome<T, E = { code: string; message: string }> =
| { status: 'ok'; data: T }
| { status: 'err'; error: E };
export function assertExhaustive(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
export type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
Quick Start Guide
- Initialize strict configuration: Apply the provided
tsconfig.json to your project. Run tsc --noEmit to surface existing type violations.
- Define domain boundaries: Create branded types for all external identifiers (IDs, tokens, keys). Wrap creation in factory functions with validation.
- Model state explicitly: Replace boolean flags and optional fields with discriminated unions. Add
assertExhaustive to every switch handling state.
- Enforce error visibility: Refactor async functions to return
Outcome<T>. Update call sites to handle both ok and err branches explicitly.
- Validate configurations: Replace type annotations on config objects with
satisfies. Lock down route maps and enums with as const to preserve literal inference.