My TypeScript code review checklist
After 5+ years building production systems, I've reviewed hundreds of pull requests. Early on, I'd focus on logic and naming. Now I focus on type safety—because a broken type boundary costs far more to debug than a naming inconsistency. This is my TypeScript code review checklist: four non-negotiable guards I apply to every PR that lands in our codebases, whether it's a trading platform processing millions of transactions or a SaaS dashboard handling user input.
1. No any — unknown plus narrowing, or proper types
any is a silent killer. It silences the compiler and invites runtime errors. I reject it immediately, but I'm not a purist—I replace it with something better.
When you genuinely don't know the shape of incoming data (from an API, a legacy system, user input), reach for unknown instead. Then narrow it with guards, assertions, or schema validation. This forces you to think about what you're actually accepting.
// ❌ Bad: any allows anything, type-checks nothing
function processUser(data: any) {
console.log(data.email.toLowerCase());
}
// ✅ Better: unknown requires narrowing
function processUser(data: unknown) {
if (typeof data !== 'object' || !data) {
throw new Error('data must be an object');
}
if (!('email' in data) || typeof data.email !== 'string') {
throw new Error('email must be a string');
}
console.log(data.email.toLowerCase());
}
// ✅ Best: proper type, ideally validated at boundary
type UserData = {
email: string;
name: string;
age?: number;
};
function processUser(data: UserData) {
console.log(data.email.toLowerCase());
}
// Validation happens at the entry point (API controller, webhook handler, etc.)I look for patterns: Are there bare any types scattered through the codebase? Did someone hit a type error and just add any instead of fixing it? I flag it and ask: "What shape should this be? Let's define it properly."
If you're working with external APIs or third-party libraries that lack good typings, generate types from JSON schemas using tools like json-schema-to-typescript, or hand-write a strict interface. The friction upfront saves debugging time later.
2. Boundaries validated — External data parsed with schemas
The boundary is where untrusted data enters your system: API endpoints, webhooks, file uploads, database queries, even environment variables. This is where type safety must be enforced, not assumed.
TypeScript's static types only exist at compile time. Once code runs, a malformed request can slip through. I look for validation at every boundary using runtime schema validators like zod, io-ts, or ajv.
// ❌ Bad: No validation, trusts the client
app.post('/users', (req, res) => {
const user: User = req.body;
db.users.create(user);
res.json(user);
});
// ✅ Good: Schema validation at the boundary
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().positive().optional(),
});
type CreateUser = z.infer;
app.post('/users', (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const user = result.data;
db.users.create(user);
res.json(user);
});In a ride-hailing platform I worked on, a webhook from a payment provider wasn't validated. A malformed event slipped through and crashed the booking service. Now, every external integration has a schema guard.
This includes database queries. If your query builder doesn't guarantee types, cast the result to unknown and validate it. Same with environment variables—don't assume process.env.DATABASE_URL exists. Parse it.
// ✅ Validate environment at startup
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const env = EnvSchema.parse(process.env);
export default env;3. Exhaustive handling — Unions handled with never checks
Union types are powerful—they let you model real-world states precisely. But they're only useful if you handle every case. Incomplete handling is a type-safety bug.
I look for discriminated unions (tagged unions) with exhaustive switch statements, enforced by TypeScript's never type.
type FetchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
// ❌ Bad: Misses the 'error' case
function render(state: FetchState) {
switch (state.status) {
case 'idle':
return <p>Ready to fetch</p>;
case 'loading':
return <p>Loading...</p>;
case 'success':
return ;
}
}
// ✅ Good: Exhaustive, with never check
function render(state: FetchState) {
switch (state.status) {
case 'idle':
return <p>Ready to fetch</p>;
case 'loading':
return <p>Loading...</p>;
case 'success':
return ;
case 'error':
return <p>Error: {state.error}</p>;
default:
const _exhaustive: never = state;
throw new Error(`Unhandled case: ${_exhaustive}`);
}
}That const _exhaustive: never = state line is intentional. If you add a new union member and forget to handle it, TypeScript errors immediately. No silent bugs. I never approve a PR with union types that lacks this guard.
This pattern scales. I've used it in trading platforms to ensure every order state is handled, in CRM systems to handle every contact status, and in SaaS dashboards to render every UI state.
4. Readable types — Complex types named and documented
A type that's hard to read is a type that will be misused. I reject inline complex types and ask for names.
// ❌ Hard to read: What does this mean?
function createOrder(
user: { id: string; email: string; tier: 'free' | 'pro' | 'enterprise' },
items: { sku: string; qty: number; price: number }[],
opts?: { coupon?: string; notify?: boolean }
): Promise<{ id: string; total: number; items: { sku: string; qty: number }[] }> {
// ...
}
// ✅ Clear: Types are named and documented
type UserProfile = {
id: string;
email: string;
tier: 'free' | 'pro' | 'enterprise';
};
type OrderLineItem = {
sku: string;
qty: number;
price: number;
};
type CreateOrderOptions = {
/** Applied at checkout; must be valid */
coupon?: string;
/** Send confirmation email to user */
notify?: boolean;
};
type OrderResponse = {
id: string;
total: number;
items: Pick[];
};
function createOrder(
user: UserProfile,
items: OrderLineItem[],
opts?: CreateOrderOptions
): Promise {
// ...
}Good naming takes seconds but saves hours for the next person reading this code (often future you). I also look for JSDoc comments on tricky generics or conditional types.
/**
* Extract keys from T where the value is assignable to V.
* @example FilterKeys<{ a: string; b: number }, string> => 'a'
*/
type FilterKeys = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];Common mistakes
- Overly broad optional properties:
type Config = { api?: string; timeout?: number }hides which properties are actually required. Use required fields and a separate partial type if needed. - Validation only at one layer: A controller validates input, but a service function accepts the same data as
any. Every public function should defend its inputs or assume they're already validated upstream (document this). - Ignoring type inference: Explicitly typing everything is verbose. Let TypeScript infer where it's clear, but be explicit at boundaries and public APIs.
- Unhandled Promise rejections in union checks: You exhaust all union members, but the async operation itself fails. Use proper error handling in addition to type exhaustion.
Key takeaways
- Replace
anywithunknownplus narrowing, or define proper types upfront. - Validate all external data at system boundaries using runtime schema validators like Zod.
- Use discriminated unions with exhaustive
neverchecks to prevent missed cases. - Name complex types and document them with JSDoc to improve readability and prevent misuse.
- Type safety is not about strictness for its own sake—it's about catching bugs early and making code intent clear to the next reader.






Comments
Loading comments…