es, the validation fails immediately with a clear error, rather than causing a cryptic runtime crash deep in the business logic.
2. Replace Vague Shapes with Domain Literals
Using string for concepts like roles, statuses, or priorities invites errors. Any string can be assigned, including typos or values the backend doesn't support. Replace vague primitives with literal unions that reflect the actual domain constraints.
// Vague: allows any string
interface OrderV1 {
priority: string;
fulfillmentMethod: string;
}
// Constrained: only valid domain values allowed
type OrderPriority = "standard" | "express" | "overnight";
type FulfillmentMethod = "pickup" | "shipping" | "digital";
interface Order {
priority: OrderPriority;
fulfillmentMethod: FulfillmentMethod;
}
function processOrder(order: Order) {
// TypeScript now enforces that priority is one of the three literals
if (order.priority === "overnight") {
// Specific logic for overnight orders
}
}
Rationale: Literal unions act as inline documentation and enforce business rules at the type level. They prevent typos and ensure that refactors to domain logic are caught by the compiler.
3. Use Branded Types for Nominal Safety
Structural typing in TypeScript means that { id: string } is compatible with { id: string } even if they represent different concepts. This can lead to dangerous mix-ups, such as passing a ProductId where a WarehouseId is expected. Branded types introduce nominal typing, making distinct IDs incompatible at compile time.
// Brand utility
type Brand<T, B extends string> = T & { readonly __brand: B };
// Define distinct IDs
type SkuId = Brand<string, "SkuId">;
type BatchId = Brand<string, "BatchId">;
// Factory functions to create branded values safely
function createSkuId(id: string): SkuId {
if (!id.startsWith("SKU-")) {
throw new Error("Invalid SKU format");
}
return id as SkuId;
}
function createBatchId(id: string): BatchId {
if (!id.startsWith("BAT-")) {
throw new Error("Invalid Batch format");
}
return id as BatchId;
}
// Function signature enforces correct ID type
function reconcileInventory(sku: SkuId, batch: BatchId) {
// Implementation
}
// Usage: TypeScript prevents swapping arguments
const mySku = createSkuId("SKU-12345");
const myBatch = createBatchId("BAT-98765");
reconcileInventory(mySku, myBatch); // OK
// reconcileInventory(myBatch, mySku); // Error: Type 'BatchId' is not assignable to 'SkuId'
Rationale: Branded types prevent a class of bugs where IDs are swapped or misused. The brand is erased at runtime, so there is no performance cost, but the compiler enforces strict usage.
4. Model State with Discriminated Unions
Boolean flags for state management (e.g., isLoading, hasError, data) create impossible states. You can end up with isLoading: true and hasError: true simultaneously, or data present while isLoading is true. Discriminated unions model state machines explicitly, making impossible states unrepresentable.
// Boolean soup: allows invalid combinations
interface CheckoutStateV1 {
isProcessing: boolean;
error?: string;
result?: CheckoutResult;
}
// Discriminated union: only valid states exist
type CheckoutState =
| { phase: "idle" }
| { phase: "validating"; step: "cart" | "payment" }
| { phase: "processing"; progress: number }
| { phase: "success"; orderId: string }
| { phase: "failed"; reason: "payment_declined" | "inventory_error" | "timeout" };
function renderCheckout(state: CheckoutState) {
switch (state.phase) {
case "idle":
return <StartButton />;
case "validating":
return <Spinner message={`Validating ${state.step}...`} />;
case "processing":
return <ProgressBar value={state.progress} />;
case "success":
return <OrderConfirmation id={state.orderId} />;
case "failed":
return <ErrorBanner reason={state.reason} />;
}
}
Rationale: Discriminated unions force exhaustive handling of all states. When a new state is added, the compiler highlights every place that needs to handle it, preventing regressions during feature development.
5. Preserve Inference with satisfies
When defining configuration objects, developers often use type annotations that widen the type, losing literal information. The satisfies operator checks that a value matches a type without widening it, preserving narrow inference for downstream usage.
type DatabaseConfig = {
host: string;
port: number;
ssl: boolean;
replicas: string[];
};
// Using `as` or annotation widens the type
const configBad: DatabaseConfig = {
host: "db.example.com",
port: 5432,
ssl: true,
replicas: ["replica-1", "replica-2"],
};
// configBad.replicas is string[], losing literal info
// Using `satisfies` preserves literals
const configGood = {
host: "db.example.com",
port: 5432,
ssl: true,
replicas: ["replica-1", "replica-2"],
} satisfies DatabaseConfig;
// configGood.replicas is readonly ["replica-1", "replica-2"]
// This allows safer iteration and prevents accidental mutation
Rationale: satisfies provides the best of both worlds: type checking against the expected shape and preservation of narrow types for better inference. This is essential for configuration objects, route maps, and feature flags.
6. Eliminate Type Theater
Avoid creating type aliases that add no safety or clarity. Patterns like type Result = any, type Callback = (...args: any[]) => any, or type StringOrNumber = string | number are placeholders that defer decisions and increase cognitive load. If a type cannot be constrained, use unknown and narrow it explicitly.
// Type theater: adds noise, no safety
type AnyDict = Record<string, any>;
type GenericCallback = (...args: any[]) => any;
// Better: Constrain generics or use unknown
type EntityMap<T> = Record<string, T>;
type EventHandler<T> = (event: T) => void;
// If truly dynamic, use unknown and narrow
function processPayload(payload: unknown) {
if (typeof payload === "object" && payload !== null && "id" in payload) {
// Narrowed to object with id
}
}
Rationale: Good TypeScript is about constraints, not aliases. Every type should communicate intent and enforce rules. If a type doesn't do both, it's likely noise.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
The any Backdoor | Using any bypasses all type checking, reintroducing runtime risks. It often spreads through codebases via copy-paste or quick fixes. | Replace any with unknown and use type guards or narrowing. If a generic is needed, constrain it with extends. |
| Runtime Blindness | Assuming TypeScript validates JSON or external data. Type assertions (as T) do not check runtime shape. | Implement runtime validation at all external boundaries using schema libraries like Zod or Valibot. |
| Boolean State Explosion | Using multiple booleans to track state (e.g., isLoading, isError, isSuccess). This leads to impossible combinations. | Use discriminated unions to model state machines. Each state has a distinct shape, preventing invalid combinations. |
| Over-Branding | Branding every string type, even where mix-ups are impossible or harmless. This adds boilerplate without value. | Brand only where ID confusion causes bugs (e.g., cross-resource IDs, security tokens). Use simple strings for internal, low-risk values. |
| Config Type Erosion | Using type annotations on config objects that widen literals, losing inference for downstream code. | Use the satisfies operator to check types while preserving narrow inference. |
| Generic Leaks | Using any inside generics (e.g., Promise<any>, Array<any>). This hides type errors in async or collection operations. | Constrain generics explicitly. Use Promise<unknown> or Array<T> with proper type parameters. |
| Ignoring Exhaustiveness | Failing to handle all cases in discriminated unions or switch statements. New states can be added without updating handlers. | Use never checks in switch statements or rely on TypeScript's strict mode to catch missing cases. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| ID mix-up risk | Branded Type | Prevents compile-time errors where IDs are swapped. High safety for low runtime cost. | Low dev cost; zero runtime cost. |
| Simple toggle | Boolean | Simplicity outweighs complexity for binary states with no intermediate values. | Minimal. |
| Complex flow | Discriminated Union | Eliminates impossible states and forces exhaustive handling. Critical for reliability. | Medium dev cost; high refactoring safety. |
| External data | Runtime Validation | TypeScript cannot validate JSON. Runtime checks prevent crashes from malformed data. | Low dev cost; prevents high-cost runtime bugs. |
| Configuration | satisfies Operator | Preserves literal inference while checking against expected shape. | Minimal; improves DX. |
| Dynamic content | unknown + Narrowing | Safer than any; forces explicit checks before usage. | Low dev cost; prevents type leaks. |
Configuration Template
Use this tsconfig.json snippet as a baseline for strict type safety. These flags enforce the constraints necessary for the patterns above.
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}
Key Flags:
strict: Enables all strict type-checking options.
exactOptionalPropertyTypes: Ensures optional properties cannot be assigned undefined explicitly, catching subtle bugs.
noImplicitReturns: Ensures all code paths in a function return a value, preventing undefined leaks.
Quick Start Guide
- Enable Strict Mode: Update
tsconfig.json to include "strict": true and verify the build passes. Fix any immediate errors.
- Install Validation Library: Add Zod (
npm install zod) to handle runtime validation at boundaries.
- Audit Top 5 Boundaries: Identify the five most critical API or database interactions. Add Zod schemas and replace type assertions with
.parse() calls.
- Refactor One State Machine: Pick a component or service with boolean state flags. Convert it to a discriminated union and update the consumer logic.
- Brand Two IDs: Identify two IDs that are frequently confused or passed across boundaries. Create branded types and factory functions, then update usage sites.
By implementing these patterns, you transform TypeScript from a syntax layer into a robust engineering tool that enforces constraints, models domain logic, and eliminates entire classes of bugs before they reach production.