TypeScript Strict Mode
Every type decision is a contract. Weak types are silent bugs.
Rule 1 — Never : any
: any disables the type checker. It is a lie that compiles.
Before
function processUser(user: any) {
return user.name.toUpperCase(); // crashes if name is undefined — no warning
}
async function fetchData(): Promise<any> {
const res = await fetch('/api/data');
return res.json(); // caller gets zero guidance
}
After
function processUser(user: unknown): string {
if (!isUser(user)) throw new TypeError('Invalid user shape');
return user.name.toUpperCase(); // safe — narrowed
}
async function fetchData(): Promise<UserData> {
const res = await fetch('/api/data');
const raw = await res.json();
return UserDataSchema.parse(raw); // validated at runtime boundary
}
Use unknown when the type truly isn't known yet — then narrow it before use.
Rule 2 — Required tsconfig Flags
Minimum strict configuration for production TypeScript:
// tsconfig.json
{
"compilerOptions": {
"strict": true, // enables all strict checks
"noUncheckedIndexedAccess": true, // arr[0] is T | undefined, not T
"exactOptionalPropertyTypes": true, // {x?: string} ≠ {x: string | undefined}
"noImplicitReturns": true, // all code paths must return
"noFallthroughCasesInSwitch": true, // exhaustive switch cases
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler" // or "node16" for Node projects
}
}
strict: true alone is not enough. noUncheckedIndexedAccess alone prevents a whole class of runtime crashes.
Rule 3 — Branded Types for IDs
A UserId string and a PostId string are both string at runtime. TypeScript won't stop you passing one where the other is required — unless you brand them.
Before
type UserId = string;
type PostId = string;
// TypeScript allows this — bug compiles silently
function deletePost(postId: PostId) { /* ... */ }
deletePost(userId); // wrong ID type, no error
After
// Branded type pattern
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
// Smart constructors validate + brand at system boundary
function toUserId(raw: string): UserId {
if (!raw || raw.length < 1) throw new Error('Invalid user ID');
return raw as UserId;
}
function toPostId(raw: string): PostId {
if (!raw || raw.length < 1) throw new Error('Invalid post ID');
return raw as PostId;
}
function deletePost(postId: PostId) { /* ... */ }
deletePost(userId); // Type error: UserId is not assignable to PostId
Apply branded types to: user IDs, resource IDs, currency amounts, percentages, validated emails.
Rule 4 — Type Narrowing Patterns
Type Guard (is*)
interface User { name: string; email: string; }
// Reusable, testable guard
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Record<string, unknown>).name === 'string' &&
typeof (value as Record<string, unknown>).email === 'string'
);
}
// Usage
if (isUser(rawData)) {
console.log(rawData.name); // fully typed, safe
}
Assertion (assert*)
function assertUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new TypeError(`Expected User, got: ${JSON.stringify(value)}`);
}
}
// Usage — throws on invalid, narrows on success
assertUser(rawData);
console.log(rawData.name); // TypeScript knows it's a User here
Discriminated Unions
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
function handleResult<T>(result: Result<T>): T {
if (!result.success) throw new Error(result.error); // narrowed to failure branch
return result.data; // narrowed to success branch
}
Rule 5 — Zod for Runtime Validation
TypeScript types exist only at compile time. At runtime boundaries (API responses, form inputs, env vars, WebSocket messages), the data is unknown. Use Zod to enforce types at runtime.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']),
createdAt: z.coerce.date(),
});
// Infer TypeScript type from schema — single source of truth
type User = z.infer<typeof UserSchema>;
// At the boundary (API handler, form submit, message receiver)
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.flatten() });
}
const user = result.data; // fully typed User
Never declare a TypeScript type for external data and then separately write a validator. Define the Zod schema, derive the type from it.
Anti-Patterns
| Pattern | Problem | Fix |
|---|---|---|
: any |
Disables type checker | Use unknown + narrowing |
as T without guard |
Lying to compiler | Use type guard or Zod |
// @ts-ignore |
Silences real errors | Fix the root cause |
| Array access without check | Runtime crash on undefined | Enable noUncheckedIndexedAccess |
JSON.parse() typed as known shape |
Runtime mismatch | z.parse(JSON.parse(...)) |
| Parallel type + validator definitions | Out-of-sync drift | Derive type from Zod schema |