TypeScript Best Practices for Clean, Maintainable Code

December 8, 2024

TypeScript Best Practices for Clean, Maintainable Code

TypeScript shines when it helps you model reality — not when it forces you to fight types all day. A few small defaults can make a codebase feel calmer, safer, and easier to refactor. This comprehensive guide covers essential patterns, configuration tips, and real-world strategies for writing excellent TypeScript code.

TypeScript code on screen

Why TypeScript Matters

TypeScript isn't just about catching errors — it's about:

  • Self-documenting code - Types serve as documentation
  • Refactoring confidence - Rename with certainty
  • Better IDE experience - Autocomplete and inline docs
  • Catch bugs early - At compile time, not runtime
  • Team collaboration - Clear contracts between modules

Essential Configuration

Strict Mode is Your Friend

{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "isolatedModules": true, "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }

Key Strict Options Explained

OptionWhat It DoesWhy Enable
strictNullChecksnull and undefined are separate typesPrevents null reference errors
noImplicitAnyVariables must have explicit typesCatches untyped code
noUncheckedIndexedAccessArray/object access may return undefinedSafe array/object access
exactOptionalPropertyTypes? means exactly undefined, not missingPrecise optional handling

Type Fundamentals

Interfaces vs Type Aliases

// Interface - extendable, can be augmented interface User { id: string; name: string; email: string; } interface AdminUser extends User { role: 'admin'; permissions: string[]; } // Declaration merging - add properties later interface User { avatar?: string; } // Type alias - more flexible for unions, mapped types type Status = 'idle' | 'loading' | 'success' | 'error'; type ApiResponse<T> = | { status: 'success'; data: T } | { status: 'error'; error: string }; // Use interface for object shapes declare const user: User; // Use type for unions, primitives, tuples type Point = [number, number]; type ID = string | number;

The any Anti-Pattern

// ❌ BAD: Using any bypasses type safety function processData(data: any) { return data.value.toUpperCase(); // Runtime error waiting to happen } // ✅ GOOD: Use unknown and type narrowing function processData(data: unknown) { if (typeof data === 'object' && data !== null && 'value' in data) { const value = (data as { value: unknown }).value; if (typeof value === 'string') { return value.toUpperCase(); } } throw new Error('Invalid data format'); } // ✅ BETTER: Define proper types interface DataPayload { value: string; } function processData(data: DataPayload): string { return data.value.toUpperCase(); }

Null Safety Patterns

// ❌ BAD: Potential null reference function getUserName(user: User | null): string { return user.name; // Error with strictNullChecks } // ✅ GOOD: Null checks function getUserName(user: User | null): string { if (user === null) { return 'Anonymous'; } return user.name; } // ✅ BETTER: Optional chaining with nullish coalescing function getUserName(user: User | null): string { return user?.name ?? 'Anonymous'; } // ✅ EXCELLENT: Non-null assertion (when you're certain) function processUser(user: User | null): void { if (!user) throw new Error('User required'); // After check, TypeScript knows user is not null console.log(user.name); // Safe }

Advanced Type Patterns

Discriminated Unions

// Define different event types type UserEvent = | { type: 'user:login'; userId: string; timestamp: Date } | { type: 'user:logout'; userId: string; timestamp: Date } | { type: 'user:signup'; userId: string; email: string; timestamp: Date }; // Type-safe event handler function handleEvent(event: UserEvent): void { // TypeScript narrows based on the discriminant switch (event.type) { case 'user:login': console.log(`User ${event.userId} logged in at ${event.timestamp}`); break; case 'user:logout': console.log(`User ${event.userId} logged out at ${event.timestamp}`); break; case 'user:signup': console.log(`New user ${event.userId} signed up with ${event.email}`); break; default: // Exhaustiveness check const _exhaustive: never = event; throw new Error(`Unhandled event: ${_exhaustive}`); } }

Branded Types for Type Safety

// Create unique types that are structurally identical but semantically different type UserId = string & { readonly brand: unique symbol }; type PostId = string & { readonly brand: unique symbol }; // Factory functions ensure type safety function createUserId(id: string): UserId { return id as UserId; } function createPostId(id: string): PostId { return id as PostId; } // Now these are incompatible const userId = createUserId('user-123'); const postId = createPostId('post-456'); function fetchUser(id: UserId) { // Implementation } fetchUser(userId); // ✅ OK fetchUser(postId); // ❌ Error: Type 'PostId' is not assignable to 'UserId'

The Result Type Pattern

type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; // Safe function that can't throw function parseJSON<T>(json: string): Result<T, SyntaxError> { try { return { success: true, data: JSON.parse(json) as T }; } catch (error) { return { success: false, error: error instanceof SyntaxError ? error : new SyntaxError('Invalid JSON') }; } } // Usage const result = parseJSON<User>('{"name": "John"}'); if (result.success) { console.log(result.data.name); // TypeScript knows data is User } else { console.error(result.error.message); // TypeScript knows error is SyntaxError } // Helper for unwrap or throw function unwrap<T>(result: Result<T>): T { if (result.success) return result.data; throw result.error; }

React + TypeScript Patterns

Component Props Patterns

// Base props with HTML attributes interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'danger'; isLoading?: boolean; } export function Button({ variant = 'primary', isLoading = false, children, ...props }: ButtonProps) { return ( <button className={`btn btn-${variant}`} disabled={isLoading || props.disabled} {...props} > {isLoading ? 'Loading...' : children} </button> ); } // Generic component interface ListProps<T> { items: T[]; renderItem: (item: T) => React.ReactNode; keyExtractor: (item: T) => string | number; } export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return ( <ul> {items.map(item => ( <li key={keyExtractor(item)}>{renderItem(item)}</li> ))} </ul> ); } // Usage <List items={users} renderItem={user => <span>{user.name}</span>} keyExtractor={user => user.id} />

Custom Hooks with TypeScript

// Typed useState with complex initializers function useLocalStorage<T>(key: string, initialValue: T) { const [storedValue, setStoredValue] = useState<T>(() => { try { const item = window.localStorage.getItem(key); return item ? (JSON.parse(item) as T) : initialValue; } catch { return initialValue; } }); const setValue = useCallback((value: T | ((prev: T) => T)) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); window.localStorage.setItem(key, JSON.stringify(valueToStore)); } catch (error) { console.error(`Error setting localStorage key "${key}":`, error); } }, [key, storedValue]); return [storedValue, setValue] as const; } // Usage const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');

API Client Types

// API response types interface ApiError { code: string; message: string; details?: Record<string, string[]>; } interface ApiResponse<T> { data: T; meta?: { page: number; limit: number; total: number; }; } // Generic API client type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; interface RequestConfig<TBody = unknown> { method: HttpMethod; url: string; body?: TBody; headers?: Record<string, string>; } async function apiClient<TResponse, TBody = unknown>( config: RequestConfig<TBody> ): Promise<TResponse> { const response = await fetch(config.url, { method: config.method, headers: { 'Content-Type': 'application/json', ...config.headers, }, body: config.body ? JSON.stringify(config.body) : undefined, }); if (!response.ok) { const error: ApiError = await response.json(); throw new ApiException(error); } return response.json() as Promise<TResponse>; } // Typed API methods const api = { getUsers: () => apiClient<ApiResponse<User[]>>({ method: 'GET', url: '/api/users' }), getUser: (id: string) => apiClient<ApiResponse<User>>({ method: 'GET', url: `/api/users/${id}` }), createUser: (data: CreateUserDto) => apiClient<ApiResponse<User>, CreateUserDto>({ method: 'POST', url: '/api/users', body: data, }), };

Utility Types

Built-in Utility Types

interface User { id: string; name: string; email: string; age: number; password: string; createdAt: Date; } // Pick - select specific properties type UserProfile = Pick<User, 'id' | 'name' | 'email'>; // { id: string; name: string; email: string } // Omit - exclude specific properties type PublicUser = Omit<User, 'password'>; // Everything except password // Partial - all properties optional type UpdateUserDto = Partial<User>; // All fields optional for updates // Required - all properties required type CreateUserDto = Required<Pick<User, 'name' | 'email'>>; // name and email required // Readonly - immutable type type ImmutableUser = Readonly<User>; // Cannot modify after creation // Record - object with specific key/value types type UserCache = Record<string, User>; // { [key: string]: User } // Pick with conditionals type StringKeys<T> = { [K in keyof T]: T[K] extends string ? K : never; }[keyof T]; type UserStringFields = Pick<User, StringKeys<User>>; // { id: string; name: string; email: string }

Custom Utility Types

// Nullable type type Nullable<T> = T | null; // Non-nullable properties only type NonNullableProperties<T> = { [P in keyof T]: NonNullable<T[P]>; }; // Deep partial for nested objects type DeepPartial<T> = { [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]; }; // Extract function return type type AsyncReturnType<T extends (...args: any[]) => Promise<any>> = T extends (...args: any[]) => Promise<infer R> ? R : never; // Usage async function fetchUser(): Promise<User> { // ... } type FetchedUser = AsyncReturnType<typeof fetchUser>; // User

Error Handling Patterns

Custom Error Classes

// Base application error class AppError extends Error { constructor( message: string, public code: string, public statusCode: number = 500 ) { super(message); this.name = 'AppError'; } } // Specific error types class ValidationError extends AppError { constructor( message: string, public fields: Record<string, string[]> ) { super(message, 'VALIDATION_ERROR', 400); this.name = 'ValidationError'; } } class NotFoundError extends AppError { constructor(resource: string, id: string) { super(`${resource} with id ${id} not found`, 'NOT_FOUND', 404); this.name = 'NotFoundError'; } } // Type guard for error handling function isAppError(error: unknown): error is AppError { return error instanceof AppError; } // Usage in error boundaries try { await processPayment(order); } catch (error) { if (isAppError(error)) { // TypeScript knows this is AppError logger.error({ code: error.code, statusCode: error.statusCode, message: error.message, }); if (error instanceof ValidationError) { return { error: error.fields, status: 400 }; } } throw error; // Re-throw unexpected errors }

Configuration and Tooling

Path Mapping

{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@/components/*": ["src/components/*"], "@/utils/*": ["src/utils/*"], "@/types/*": ["src/types/*"], "@/hooks/*": ["src/hooks/*"] } } }

Import Organization

// Group imports logically // 1. External dependencies import React from 'react'; import { useQuery } from '@tanstack/react-query'; // 2. Internal absolute imports import { Button } from '@/components/ui/Button'; import { useAuth } from '@/hooks/useAuth'; // 3. Internal relative imports import { UserCard } from './UserCard'; import { formatDate } from './utils'; // 4. Types import type { User } from '@/types/user';

Performance and Bundle Size

Const Assertions

// Without const assertion - wider type const config = { apiUrl: 'https://api.example.com', timeout: 5000, retries: 3, }; // typeof config: { apiUrl: string; timeout: number; retries: number } // With const assertion - literal types const config = { apiUrl: 'https://api.example.com', timeout: 5000, retries: 3, } as const; // typeof config: { readonly apiUrl: "https://api.example.com"; readonly timeout: 5000; readonly retries: 3 } // Useful for discriminated unions const ACTIONS = { INCREMENT: 'counter/increment', DECREMENT: 'counter/decrement', RESET: 'counter/reset', } as const; type ActionType = typeof ACTIONS[keyof typeof ACTIONS]; // "counter/increment" | "counter/decrement" | "counter/reset"

Type-Only Imports

// Explicit type imports (helps with tree-shaking) import type { User } from '@/types/user'; import type { ReactNode } from 'react'; // Or use 'import type' for all types import { type User, type Order, saveUser } from '@/types';

Testing Types

Type Testing with ts-expect-error

// Test that a type error occurs where expected // @ts-expect-error - should fail without required field const invalidUser: User = { id: '1' }; // name and email missing // Test that types are assignable const validUser: User = { id: '1', name: 'John', email: 'john@example.com', age: 30, password: 'secret', createdAt: new Date(), };

Type-Level Testing Libraries

// Using type testing utilities import type { Equal, Expect } from '@type-challenges/utils'; // Test that types match type cases = [ Expect<Equal<ReturnType<typeof parseUser>, User>>, Expect<Equal<Parameters<typeof createUser>[0], CreateUserDto>>, ];

Migration Strategies

Gradual Migration from JavaScript

// 1. Rename files from .js to .ts (or .tsx for React) // 2. Start with loose compiler options { "compilerOptions": { "allowJs": true, "checkJs": false, "noImplicitAny": false, "strict": false } } // 3. Add types incrementally // @ts-check comment at top of file enables checking // 4. Enable stricter options file by file // @ts-expect-error for known issues during migration // 5. Gradually tighten until full strict mode

Common Mistakes to Avoid

// ❌ Overusing type assertions const user = {} as User; // Bypasses all type checking // ✅ Use proper type guards function isUser(obj: unknown): obj is User { return ( typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj ); } // ❌ Using any for callbacks function processItems(items: any[]) { items.map(item => item.toUpperCase()); // No error, may fail at runtime } // ✅ Use generics function processItems<T>(items: T[], processor: (item: T) => string) { return items.map(processor); } // ❌ Mutating readonly arrays const readonlyArray: readonly string[] = ['a', 'b']; readonlyArray.push('c'); // Error (good!) // ✅ Create new array const newArray = [...readonlyArray, 'c']; // ❌ Implicit returns function getUser(): User { if (condition) { return fetchUser(); } // Missing return - compiles without strict mode } // ✅ Enable noImplicitReturns or add explicit return function getUser(): User | null { if (condition) { return fetchUser(); } return null; }

Best Practices Checklist

  • Enable strict mode in tsconfig.json
  • Use interfaces for object shapes, types for unions
  • Avoid any - use unknown with type guards
  • Use discriminated unions for complex state
  • Brand types for type-safe IDs
  • Leverage utility types (Pick, Omit, Partial)
  • Write custom error classes with proper types
  • Use const assertions for literal types
  • Separate type-only imports for tree-shaking
  • Add type tests for complex utility types

Conclusion

TypeScript is most powerful when it models your domain accurately. Focus on:

  1. Enable strict mode - The earlier, the better
  2. Write precise types - Don't be afraid of complexity
  3. Use the type system to guide design - If types are awkward, the design might be
  4. Test your types - Especially for library code
  5. Iterate and refactor - Types make refactoring safer

The best TypeScript code reads like good documentation: clear names, predictable shapes, and errors that point you to the fix. Invest time in your types, and they'll pay dividends in maintainability and developer confidence.

GitHub
LinkedIn
X
youtube