TypeScript Best Practices for Modern Web Development
After years of working with TypeScript in large-scale applications, I've learned that proper typing isn't just about catching errors—it's about creating maintainable, self-documenting code. Let's explore the best practices that will help you write better TypeScript code.
Type System Fundamentals
1. Advanced Type Definitions
Leverage TypeScript's type system effectively:
// Use discriminated unions for better type safety type Success<T> = { status: "success"; data: T; }; type Error = { status: "error"; error: string; }; type ApiResponse<T> = Success<T> | Error; // Function that handles both cases function handleResponse<T>(response: ApiResponse<T>): T | null { if (response.status === "success") { return response.data; } console.error(response.error); return null; }
2. Generic Constraints
Use constraints to create more flexible, type-safe functions:
interface HasId { id: string | number; } class Repository<T extends HasId> { private items: Map<string | number, T>; constructor() { this.items = new Map(); } add(item: T): void { this.items.set(item.id, item); } get(id: string | number): T | undefined { return this.items.get(id); } update(item: T): boolean { if (!this.items.has(item.id)) return false; this.items.set(item.id, item); return true; } }
Type Safety Best Practices
1. Strict Type Checking
Enable strict mode and handle all cases:
// tsconfig.json { "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, "noImplicitThis": true, "useUnknownInCatchVariables": true } } // Example of strict null checks function getUser(id: string): User | null { const user = database.find(id); if (!user) return null; return user; } // Usage with null checking const user = getUser('123'); if (user) { // TypeScript knows user is not null here console.log(user.name); }
2. Type Guards
Implement custom type guards for better type narrowing:
interface Admin { role: "admin"; adminPrivileges: string[]; } interface User { role: "user"; userPreferences: Record<string, unknown>; } type SystemUser = Admin | User; // Custom type guard function isAdmin(user: SystemUser): user is Admin { return user.role === "admin"; } function handleUser(user: SystemUser) { if (isAdmin(user)) { // TypeScript knows user is Admin here console.log(user.adminPrivileges); } else { // TypeScript knows user is User here console.log(user.userPreferences); } }
Advanced Patterns
1. Builder Pattern with Types
Implement type-safe builder pattern:
class QueryBuilder<T> { private query: Partial<T> = {}; where<K extends keyof T>(key: K, value: T[K]): QueryBuilder<T> { this.query[key] = value; return this; } build(): Partial<T> { return this.query; } } // Usage interface User { name: string; age: number; email: string; } const query = new QueryBuilder<User>() .where("age", 25) .where("name", "John") .build();
2. Factory Pattern with Type Safety
Create type-safe factory functions:
interface Logger { log(message: string): void; } class ConsoleLogger implements Logger { log(message: string): void { console.log(`[Console]: ${message}`); } } class FileLogger implements Logger { log(message: string): void { // Log to file console.log(`[File]: ${message}`); } } type LoggerType = "console" | "file"; const createLogger = (type: LoggerType): Logger => { switch (type) { case "console": return new ConsoleLogger(); case "file": return new FileLogger(); default: throw new Error(`Unknown logger type: ${type}`); } };
Error Handling
1. Custom Error Types
Create specific error types:
class ApplicationError extends Error { constructor(message: string, public code: string, public status: number) { super(message); this.name = "ApplicationError"; } } class ValidationError extends ApplicationError { constructor(message: string) { super(message, "VALIDATION_ERROR", 400); this.name = "ValidationError"; } } // Usage with type checking try { throw new ValidationError("Invalid input"); } catch (error) { if (error instanceof ValidationError) { console.log(error.code); // VALIDATION_ERROR } }
2. Result Type Pattern
Implement the Result type pattern:
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }; async function fetchUser(id: string): Promise<Result<User, Error>> { try { const user = await database.users.find(id); if (!user) { return { ok: false, error: new Error(`User not found: ${id}`), }; } return { ok: true, value: user }; } catch (error) { return { ok: false, error: error instanceof Error ? error : new Error("Unknown error"), }; } } // Usage const result = await fetchUser("123"); if (result.ok) { // TypeScript knows result.value is User console.log(result.value.name); } else { // TypeScript knows result.error is Error console.error(result.error.message); }
Utility Types
1. Custom Utility Types
Create reusable utility types:
// Make all properties optional recursively type DeepPartial<T> = { [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]; }; // Make all properties required recursively type DeepRequired<T> = { [P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P]; }; // Extract properties of specific type type PropertiesOfType<T, U> = { [P in keyof T]: T[P] extends U ? P : never; }[keyof T]; // Usage interface Config { api: { url: string; port: number; }; debug: boolean; } type PartialConfig = DeepPartial<Config>; type StringKeys = PropertiesOfType<Config["api"], string>; // 'url'
Best Practices
- Enable Strict Mode: Always use TypeScript's strict mode
- Use Type Inference: Let TypeScript infer types when possible
- Avoid Type Assertions: Use type guards instead of type assertions
- Document Complex Types: Add JSDoc comments for complex types
- Use Branded Types: For type-safe identifiers
Implementation Checklist
- Configure strict TypeScript settings
- Set up proper type definitions
- Implement type guards
- Use utility types effectively
- Add error handling
- Document complex types
- Set up linting rules
- Regular type safety audits
Conclusion
TypeScript's type system is powerful, but it requires careful consideration and proper practices to be effective. By following these patterns and practices, you can write more maintainable and type-safe code.