Building Robust Applications with TypeScript
TypeScript has revolutionized how we write JavaScript, bringing type safety and enhanced developer experience to modern web development. However, to truly harness its power, you need to follow established best practices that ensure your code is maintainable, scalable, and robust.
Essential TypeScript Configuration
Strict Mode Configuration
Always start with a strict TypeScript configuration to catch potential issues early:
{ "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true } }
Path Mapping for Clean Imports
Configure path mapping to avoid relative import hell:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@/components/*": ["src/components/*"], "@/utils/*": ["src/utils/*"], "@/types/*": ["src/types/*"] } } }
Type Definition Best Practices
1. Use Interfaces for Object Shapes
// ✅ Good: Clear interface definition interface User { readonly id: string; name: string; email: string; createdAt: Date; preferences?: UserPreferences; } interface UserPreferences { theme: 'light' | 'dark'; notifications: boolean; language: string; }
2. Leverage Union Types for Flexibility
// ✅ Good: Descriptive union types type Status = 'pending' | 'approved' | 'rejected'; type Theme = 'light' | 'dark' | 'auto'; // ✅ Good: Discriminated unions type ApiResponse<T> = | { success: true; data: T } | { success: false; error: string };
3. Use Generic Types for Reusability
// ✅ Good: Reusable generic interface interface ApiClient<T> { get(id: string): Promise<T>; create(data: Omit<T, 'id'>): Promise<T>; update(id: string, data: Partial<T>): Promise<T>; delete(id: string): Promise<void>; } // Usage const userClient: ApiClient<User> = new UserApiClient(); const postClient: ApiClient<Post> = new PostApiClient();
Advanced Type Patterns
1. Utility Types for Transformation
// ✅ Good: Using utility types type CreateUserRequest = Omit<User, 'id' | 'createdAt'>; type UpdateUserRequest = Partial<Pick<User, 'name' | 'email'>>; type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
2. Conditional Types for Complex Logic
// ✅ Good: Conditional types for API responses type ApiEndpoint<T extends string> = T extends `${infer Method} ${infer Path}` ? Method extends 'GET' ? { method: 'GET'; path: Path; body?: never } : { method: Method; path: Path; body: unknown } : never; type GetUsers = ApiEndpoint<'GET /users'>; // { method: 'GET'; path: '/users'; body?: never } type CreateUser = ApiEndpoint<'POST /users'>; // { method: 'POST'; path: '/users'; body: unknown }
3. Mapped Types for Consistency
// ✅ Good: Mapped types for form handling type FormState<T> = { [K in keyof T]: { value: T[K]; error?: string; touched: boolean; }; }; type UserFormState = FormState<CreateUserRequest>;
Error Handling Patterns
1. Result Pattern for Error Handling
type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; class UserService { async getUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'NETWORK_ERROR'>> { try { const user = await this.apiClient.get(id); return { success: true, data: user }; } catch (error) { if (error.status === 404) { return { success: false, error: 'NOT_FOUND' }; } return { success: false, error: 'NETWORK_ERROR' }; } } }
2. Custom Error Types
abstract class AppError extends Error { abstract readonly code: string; abstract readonly statusCode: number; } class ValidationError extends AppError { readonly code = 'VALIDATION_ERROR'; readonly statusCode = 400; constructor(public field: string, message: string) { super(`Validation failed for ${field}: ${message}`); } } class NotFoundError extends AppError { readonly code = 'NOT_FOUND'; readonly statusCode = 404; constructor(resource: string, id: string) { super(`${resource} with id ${id} not found`); } }
Performance Optimization
1. Type-Only Imports
// ✅ Good: Type-only imports import type { User, UserPreferences } from './types'; import { validateUser } from './validators'; // ✅ Good: Mixed imports import { type ApiResponse, fetchData } from './api';
2. Lazy Type Loading
// ✅ Good: Lazy loading for large types type LazyUserDetails = () => Promise<import('./user-details').UserDetails>; class UserManager { async getUserDetails(id: string): Promise<Awaited<ReturnType<LazyUserDetails>>> { const { UserDetails } = await import('./user-details'); return new UserDetails(id); } }
Testing with TypeScript
1. Type-Safe Test Utilities
// ✅ Good: Type-safe test helpers function createMockUser(overrides: Partial<User> = {}): User { return { id: 'test-id', name: 'Test User', email: '[email protected]', createdAt: new Date(), ...overrides, }; } // ✅ Good: Type-safe API mocking type MockApiClient<T> = { [K in keyof ApiClient<T>]: jest.MockedFunction<ApiClient<T>[K]>; }; function createMockApiClient<T>(): MockApiClient<T> { return { get: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), }; }
Common Pitfalls to Avoid
1. Avoid any
Type
// ❌ Bad: Using any function processData(data: any): any { return data.someProperty; } // ✅ Good: Use generics or unknown function processData<T>(data: T): T extends { someProperty: infer P } ? P : never { return (data as any).someProperty; // Type assertion when necessary } // ✅ Better: Use unknown for truly unknown data function processUnknownData(data: unknown): string { if (typeof data === 'object' && data !== null && 'someProperty' in data) { return String((data as { someProperty: unknown }).someProperty); } throw new Error('Invalid data structure'); }
2. Avoid Deep Nesting
// ❌ Bad: Deep nesting interface DeepNested { level1: { level2: { level3: { value: string; }; }; }; } // ✅ Good: Flatten with separate interfaces interface Level3Data { value: string; } interface Level2Data { level3: Level3Data; } interface Level1Data { level2: Level2Data; } interface FlattenedStructure { level1: Level1Data; }
Conclusion
Following these TypeScript best practices will help you build more maintainable, scalable, and robust applications. Remember that TypeScript is not just about adding types—it's about creating a better development experience and catching errors before they reach production.
Key takeaways:
- Start with strict configuration to catch issues early
- Use descriptive types that communicate intent
- Leverage utility types for code reuse
- Implement proper error handling patterns
- Optimize for performance with type-only imports
- Write type-safe tests for better reliability
By incorporating these practices into your development workflow, you'll write TypeScript code that not only works but is also a joy to maintain and extend.
Want to learn some about Next.js? Check out our Exploring Next.js 15 for tips.