.jsonactivates a suite of checks that catch null/undefined access, implicitany`, and function type mismatches. Without it, TypeScript defaults to permissive behavior that silently accepts unsafe patterns.
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
Rationale: noUncheckedIndexedAccess ensures that array/object index operations return T | undefined, preventing accidental property access on missing keys. exactOptionalPropertyTypes distinguishes between missing properties and properties explicitly set to undefined. These flags eliminate entire categories of runtime TypeError exceptions before code ships.
Step 2: Replace any with unknown at Ingress Points
External data should never be typed as any. Use unknown to acknowledge that the shape is unverified, then force explicit narrowing.
// Unsafe: any propagates unchecked
async function fetchPaymentData(): Promise<any> {
const response = await fetch('/api/v2/transactions');
return response.json();
}
// Safe: unknown forces validation
async function fetchPaymentData(): Promise<unknown> {
const response = await fetch('/api/v2/transactions');
return response.json();
}
Step 3: Implement Runtime Schema Validation
Because types are erased, external data must be validated at runtime. Schema libraries like Zod provide declarative validation that doubles as type generation.
import { z } from 'zod';
const PaymentPayloadSchema = z.object({
id: z.string().uuid(),
amount: z.number().positive(),
currency: z.enum(['USD', 'EUR', 'GBP']),
status: z.enum(['pending', 'cleared', 'failed']),
metadata: z.record(z.unknown()).optional()
});
type PaymentPayload = z.infer<typeof PaymentPayloadSchema>;
async function getValidatedPayment(raw: unknown): Promise<PaymentPayload> {
return PaymentPayloadSchema.parse(raw);
}
Rationale: z.parse() throws a detailed ZodError if the payload deviates from the schema. This fails fast at the boundary, provides actionable error messages, and returns a fully typed object. The schema acts as a single source of truth for both runtime validation and compile-time types.
Step 4: Consume with Type Guards and Narrowing
Once validated, consume the data using TypeScript's control flow analysis. Avoid casting; use guards that the compiler understands.
function processTransaction(raw: unknown) {
const payload = getValidatedPayment(raw);
if (payload.status === 'cleared') {
// Compiler knows payload.status is 'cleared'
// payload.amount is guaranteed to be a positive number
executeLedgerEntry(payload.id, payload.amount);
} else {
handlePendingOrFailed(payload);
}
}
Architecture Decision: Why separate validation from business logic? Coupling validation to consumption creates tight dependencies and makes testing difficult. By isolating schema parsing at the ingress layer, business functions receive guaranteed shapes. This enables pure function testing, simplifies mocking, and ensures that type errors are caught before they reach core logic.
Pitfall Guide
1. The any Propagation Trap
Explanation: any opts out of type checking entirely. When assigned to a variable, returned from a function, or spread into an object, it infects downstream types. The compiler stops warning about property access, method calls, or arithmetic operations.
Fix: Replace any with unknown at boundaries. Use eslint rule @typescript-eslint/no-explicit-any to enforce awareness. If a third-party library lacks types, create a minimal interface or use unknown with runtime checks instead of blanket any.
2. False Confidence in Interface Declarations
Explanation: Declaring const data: User = await fetch() does not validate the response. It tells the compiler to trust the developer. If the API returns { name: "Alice", age: "25" } (string instead of number), the assignment succeeds silently. The error surfaces later when age.toFixed() is called.
Fix: Never trust external data. Always run it through a runtime validator (Zod, io-ts, ArkType) before assigning to a typed variable. Treat interfaces as contracts, not validators.
3. Over-Reliance on as Assertions
Explanation: Type assertions (as PaymentPayload) bypass compiler checks. They are useful for known-safe scenarios but dangerous when applied to unverified data. Misuse creates "type lies" that the compiler cannot detect.
Fix: Reserve as for type widening/narrowing where the compiler lacks context (e.g., DOM APIs, legacy JS interop). For data transformation, use explicit mapping functions or schema validation instead of assertions.
4. Ignoring Discriminated Unions for State Machines
Explanation: Using generic objects for stateful data leads to invalid state combinations (e.g., status: 'failed' with amount: 100). The compiler cannot enforce mutually exclusive fields.
Fix: Use discriminated unions with a literal type or status field. This enables exhaustive checking and prevents impossible states at compile time.
type Transaction =
| { type: 'pending'; id: string; amount: number }
| { type: 'failed'; id: string; errorCode: string; reason: string }
| { type: 'cleared'; id: string; amount: number; settlementId: string };
5. Skipping Runtime Validation for "Internal" APIs
Explanation: Teams often skip validation for microservices or internal endpoints, assuming contract stability. API version drift, partial deployments, or schema migrations frequently break this assumption.
Fix: Apply the same validation strategy to internal boundaries. Use shared schema packages across services. Treat all network ingress as untrusted until proven otherwise.
Explanation: Complex type guards or recursive schema validation in tight loops or high-throughput endpoints can introduce measurable latency.
Fix: Validate at the boundary, not in the hot path. Cache validated results when possible. For performance-critical code, use lightweight runtime checks or compile-time guarantees instead of full schema parsing.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Third-party API with unstable schema | unknown + Zod validation at ingress | Catches structural changes early, provides clear error context | Low (schema maintenance) |
| Internal microservice with stable contract | Shared schema package + strict interfaces | Eliminates duplication, ensures compile-time alignment across services | Medium (package sync overhead) |
| Legacy JS interop / DOM APIs | unknown + narrow assertions + runtime checks | Preserves safety while accommodating untyped ecosystems | Low |
| High-throughput data pipeline | Validate at boundary, cache results, use lightweight checks in hot path | Prevents validation overhead from degrading throughput | Medium (architecture complexity) |
| Rapid prototyping / MVP | any temporarily, but track in CI with no-explicit-any warning | Balances velocity with technical debt visibility | Low (debt tracking) |
Configuration Template
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
// .eslintrc.cjs
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/strict'
],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/strict-boolean-expressions': 'warn'
}
};
Quick Start Guide
- Initialize strict configuration: Add
tsconfig.json with strict: true and the additional flags shown above. Run tsc --noEmit to identify existing type gaps.
- Install validation layer:
npm install zod. Create a src/schemas/ directory and define your first boundary schema (e.g., PaymentPayloadSchema).
- Replace ingress types: Locate all
fetch, axios, or JSON.parse calls. Change return types to unknown. Wrap responses in Schema.parse() or Schema.safeParse().
- Enforce lint rules: Add the ESLint configuration. Run
eslint . --ext .ts to surface any usage. Replace flagged instances with unknown + guards or schema validation.
- Verify in CI: Add
tsc --noEmit and eslint to your pipeline. Configure the build to fail on new any declarations or unsafe type assertions. Track existing violations as technical debt tickets.
Type safety is not achieved by writing more types. It is achieved by designing explicit boundaries, enforcing validation at ingress, and refusing to let unverified data pollute the core application. When the type system is treated as a defensive architecture rather than a documentation layer, runtime failures become predictable, refactoring becomes risk-free, and developer velocity scales with codebase complexity.