ain contract
interface DashboardConfig {
theme: 'light' | 'dark' | 'system';
refreshInterval: number;
features: string[];
}
// Validation without type widening
const defaultConfig = {
theme: 'system',
refreshInterval: 5000,
features: ['analytics', 'alerts'],
} satisfies DashboardConfig;
// Type alias for computed/union structures
type ConfigKey = keyof DashboardConfig; // 'theme' | 'refreshInterval' | 'features'
type ThemeVariant = DashboardConfig['theme']; // 'light' | 'dark' | 'system'
**Rationale:** `satisfies` ensures the object conforms to `DashboardConfig` while preserving literal inference for `theme` and `refreshInterval`. This prevents accidental widening to `string` or `number` when the values are later used in conditional logic or API payloads.
### Step 2: Model State with Discriminated Unions
Asynchronous operations and UI states benefit from explicit, mutually exclusive shapes. Discriminated unions enforce exhaustive handling at compile time, eliminating missing case bugs.
```typescript
type DataState<T> =
| { phase: 'initial' }
| { phase: 'fetching'; requestId: string }
| { phase: 'resolved'; payload: T; cachedAt: number }
| { phase: 'rejected'; reason: Error; retryCount: number };
function renderDashboard<T>(state: DataState<T>): string {
switch (state.phase) {
case 'initial':
return 'Awaiting data';
case 'fetching':
return `Loading request ${state.requestId}`;
case 'resolved':
return `Data loaded at ${new Date(state.cachedAt).toISOString()}`;
case 'rejected':
return `Failed: ${state.reason.message} (${state.retryCount} attempts)`;
default: {
const _assertNever: never = state;
return _assertNever;
}
}
}
Rationale: The phase property acts as a discriminant. TypeScript narrows the union automatically within each branch. The never assignment in the default case triggers a compile error if a new phase is added to DataState but not handled in the switch statement, guaranteeing exhaustive coverage.
Generics enable logic reuse across data shapes. Combined with built-in utility types, they allow precise extraction, transformation, and constraint enforcement without duplicating code.
interface MetricRecord {
id: string;
label: string;
value: number;
unit: string;
timestamp: Date;
}
// Extract subset for display
type MetricPreview = Pick<MetricRecord, 'id' | 'label' | 'value'>;
// Make all fields optional for patch operations
type MetricPatch = Partial<MetricRecord>;
// Generic fetcher with constraint
async function fetchResource<T extends { id: string }>(
endpoint: string,
resourceId: T['id']
): Promise<T> {
const response = await fetch(`${endpoint}/${resourceId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
// Usage
const chartData = await fetchResource<MetricRecord>('/api/metrics', 'cpu-usage');
Rationale: Pick and Partial derive new types from existing contracts, maintaining a single source of truth. The generic constraint T extends { id: string } ensures type safety while allowing the function to accept any resource shape that includes an id field. This pattern scales across microservices and shared libraries.
Step 4: Implement Safe Narrowing with Type Guards
Runtime type checking requires explicit narrowing. Custom type guards bridge the gap between dynamic data and static types without sacrificing performance.
interface SuccessEnvelope<T> {
ok: true;
body: T;
}
interface FailureEnvelope {
ok: false;
code: string;
detail: string;
}
type ApiResult<T> = SuccessEnvelope<T> | FailureEnvelope;
function isSuccess<T>(result: ApiResult<T>): result is SuccessEnvelope<T> {
return result.ok === true;
}
function processResponse(result: ApiResult<MetricRecord>) {
if (isSuccess(result)) {
console.log(`Metric ${result.body.label} is ${result.body.value}`);
} else {
console.warn(`API error ${result.code}: ${result.detail}`);
}
}
Rationale: The is keyword tells the compiler to narrow the union type within the if block. This eliminates the need for repeated property checks or unsafe casting. Custom guards are highly reusable and integrate seamlessly with control flow analysis.
Step 5: Enforce Compile-Time Routing with Template Literals
Template literal types enable string pattern validation at compile time. This is particularly useful for API endpoints, event names, and configuration keys.
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ResourcePath = `/v1/${'users' | 'metrics' | 'alerts'}`;
type ApiEndpoint = `${HttpMethod} ${ResourcePath}`;
// "GET /v1/users" | "POST /v1/metrics" | etc.
function registerRoute(route: ApiEndpoint): void {
console.log(`Registered: ${route}`);
}
registerRoute('GET /v1/users'); // Valid
registerRoute('PATCH /v1/users'); // Compile error: 'PATCH' not assignable
Rationale: Template literals transform string concatenation into a type-level operation. This prevents typos in route definitions, ensures consistency across client and server code, and enables IDE autocompletion for valid endpoints.
Pitfall Guide
1. The any Escape Hatch
Explanation: Developers frequently use any to bypass compiler errors during rapid prototyping. This disables all type checking for the affected value, propagating unsafety through downstream functions.
Fix: Replace any with unknown when the type is genuinely uncertain. Use type guards or assertions to narrow unknown before usage. Reserve any only for third-party libraries without type definitions, and isolate them in wrapper modules.
2. Misusing as const for Dynamic Data
Explanation: as const freezes object and array types to literal readonly values. Applying it to data that changes at runtime (e.g., form inputs, API responses) causes assignment errors and breaks mutability expectations.
Fix: Use as const exclusively for static configuration, route maps, and enum-like constants. For dynamic data, rely on interfaces or type aliases that allow mutation.
3. Ignoring Exhaustive Switch Checks
Explanation: Omitting the never assertion in discriminated union handlers allows new states to be added without updating control flow, leading to silent fallback behavior or runtime crashes.
Fix: Always include a default case that assigns the state to a never typed variable. Enable strictNullChecks and noImplicitReturns in tsconfig to enforce coverage.
4. Over-Nesting Utility Types
Explanation: Chaining multiple utility types (Pick<Omit<Partial<Record<...>>>>) creates opaque type signatures that degrade IDE performance and confuse team members.
Fix: Extract intermediate types with descriptive names. Prefer explicit interfaces for complex shapes. Use utility types for simple transformations, not architectural design.
5. Skipping Strict Compiler Flags
Explanation: Disabling strict mode or individual flags like noUncheckedIndexedAccess masks undefined access, implicit returns, and switch fallthroughs. These bugs surface in production under edge cases.
Fix: Enable strict: true as a baseline. Gradually adopt stricter flags (exactOptionalPropertyTypes, noImplicitReturns) as the codebase matures. Treat compiler warnings as errors in CI pipelines.
6. Confusing Structural vs Nominal Typing
Explanation: TypeScript uses structural typing (duck typing). Two types with identical shapes are interchangeable, even if named differently. Developers expecting nominal typing (like Java or C#) may accidentally pass incompatible data.
Fix: Use branded types or unique symbol properties to enforce nominal behavior when needed. Example: type UserId = string & { __brand: 'UserId' }. This prevents accidental assignment between semantically different strings.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static configuration (routes, themes, flags) | as const + satisfies | Preserves literals, validates shape, zero runtime cost | Low |
| Dynamic API responses | Interfaces + unknown + type guards | Handles unpredictable payloads safely | Medium |
| Shared library contracts | Interfaces + utility types | Enables extension, maintains single source of truth | Low |
| Complex state machines | Discriminated unions + exhaustive checks | Prevents missing case bugs, improves IDE navigation | Low |
| Third-party untyped modules | Wrapper module with unknown + guards | Isolates any, contains risk, enables gradual typing | Medium |
Configuration Template
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@core/*": ["src/core/*"],
"@domain/*": ["src/domain/*"],
"@infra/*": ["src/infrastructure/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
Quick Start Guide
- Initialize a new project with
npm init -y and install TypeScript: npm i -D typescript @types/node
- Generate a baseline configuration:
npx tsc --init
- Replace the generated
tsconfig.json with the Production Bundle template above
- Create a
src/ directory and add a test file with a discriminated union and type guard to verify compiler behavior
- Run
npx tsc --noEmit to validate type safety without emitting JavaScript; iterate until zero errors remain