After building 50+ production TypeScript applications, we've learned what actually matters for large codebases. Here are the patterns that make the difference.
1. Strict Configuration
Start with the strictest possible tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
}
}
2. Type-Safe API Responses
Never trust external data. Use Zod for runtime validation:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
});
type User = z.infer;
async function fetchUser(id: number): Promise {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return UserSchema.parse(data); // Runtime validation
}
3. Branded Types for Type Safety
Prevent mixing up similar primitive types:
// Bad: Easy to mix up IDs
function getUser(userId: number) { }
function getOrder(orderId: number) { }
getUser(123); // User ID
getOrder(123); // Order ID - both accept numbers!
// Good: Branded types
type UserId = number & { readonly __brand: 'UserId' };
type OrderId = number & { readonly __brand: 'OrderId' };
function createUserId(id: number): UserId {
return id as UserId;
}
function getUser(userId: UserId) { }
function getOrder(orderId: OrderId) { }
const userId = createUserId(123);
const orderId = createOrderId(456);
getUser(userId); // ✓ Works
getUser(orderId); // ✗ Type error!
4. Discriminated Unions for State
Model exclusive states with discriminated unions:
// Bad: Optional fields create invalid states
interface Request {
status: 'idle' | 'loading' | 'success' | 'error';
data?: User;
error?: Error;
}
// Bad: Can be { status: 'success', error: Error }
// Good: Discriminated union
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error };
function handleRequest(state: RequestState) {
switch (state.status) {
case 'success':
return state.data.name; // TypeScript knows data exists
case 'error':
return state.error.message; // TypeScript knows error exists
}
}
5. Utility Types for DRY Code
// Extract function parameters
type Params = T extends (...args: infer P) => any ? P : never;
function createUser(name: string, email: string) {}
type CreateUserParams = Params;
// [string, string]
// Deep Partial
type DeepPartial = {
[P in keyof T]?: T[P] extends object ? DeepPartial : T[P];
};
// Required keys only
type RequiredKeys = {
[K in keyof T]-?: {} extends Pick ? never : K;
}[keyof T];
6. Type Guards for Narrowing
interface User {
type: 'user';
name: string;
}
interface Admin {
type: 'admin';
name: string;
permissions: string[];
}
type Person = User | Admin;
// Type guard
function isAdmin(person: Person): person is Admin {
return person.type === 'admin';
}
function greet(person: Person) {
if (isAdmin(person)) {
console.log(person.permissions); // TypeScript knows it's Admin
}
}
7. Generic Constraints
// Ensure objects have specific keys
function getProperty(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'John', age: 30 };
getProperty(user, 'name'); // ✓ 'John'
getProperty(user, 'email'); // ✗ Type error
// Extend interfaces
interface HasId {
id: number;
}
function findById(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
8. Const Assertions
// Without const assertion
const colors = ['red', 'blue', 'green'];
// Type: string[]
// With const assertion
const colors = ['red', 'blue', 'green'] as const;
// Type: readonly ['red', 'blue', 'green']
type Color = typeof colors[number];
// Type: 'red' | 'blue' | 'green'
Common Pitfalls to Avoid
- ❌ Using `any` (use `unknown` instead)
- ❌ Type assertions without validation (`as User`)
- ❌ Non-null assertions (`user!`) without checks
- ❌ Ignoring TypeScript errors with `@ts-ignore`
- ❌ Optional chaining everywhere (handle nulls explicitly)
- ❌ Overly complex generic types (keep it simple)
Performance Tips
- Use `skipLibCheck: true` in tsconfig (faster compilation)
- Enable incremental compilation
- Use project references for monorepos
- Keep types in separate `.d.ts` files for large projects
- Use type-only imports: `import type { User }`