Loading...

TypeScript Best Practices: Writing Maintainable Code

July 1, 2025 • 14 min read

TypeScript Best Practices

TypeScript has become the standard for building large-scale JavaScript applications. Its type system helps catch errors early, improves developer experience, and makes code more maintainable. However, writing good TypeScript code requires understanding best practices and avoiding common pitfalls. This guide covers essential patterns and techniques for writing clean, maintainable TypeScript code.

Type System Fundamentals

Understanding TypeScript's type system is crucial for writing effective code. Let's start with the fundamentals.

Prefer Interfaces Over Type Aliases

For object shapes, prefer interfaces over type aliases. Interfaces are more extensible and provide better error messages.

// Good - Interface interface User { id: number; name: string; email: string; } // Extending interfaces interface AdminUser extends User { permissions: string[]; } // Bad - Type alias for object shapes type User = { id: number; name: string; email: string; }; // Good - Type alias for unions, primitives, or complex types type Status = 'loading' | 'success' | 'error'; type ID = string | number; type Callback<T> = (data: T) => void;

Use Strict Type Checking

Enable strict mode in your TypeScript configuration for better type safety.

// tsconfig.json { "compilerOptions": { "strict": true, "noImplicitAny": true, "noImplicitReturns": true, "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, "exactOptionalPropertyTypes": true } }

Function and Method Design

Explicit Return Types

Use explicit return types for public APIs and complex functions to improve code clarity and catch errors early.

// Good - Explicit return type function calculateTotal(items: CartItem[]): number { return items.reduce((total, item) => total + item.price * item.quantity, 0); } // Good - Async functions async function fetchUser(id: number): Promise<User> { const response = await fetch(`/api/users/${id}`); return response.json(); } // Good - Complex return types function processData<T>(data: T[]): { result: T[]; count: number } { return { result: data.filter(item => item !== null), count: data.length }; }

Function Overloading

Use function overloading to provide multiple signatures for the same function.

// Function overloading function createElement(tag: 'div'): HTMLDivElement; function createElement(tag: 'span'): HTMLSpanElement; function createElement(tag: string): HTMLElement { return document.createElement(tag) as HTMLElement; } // Usage const div = createElement('div'); // Type: HTMLDivElement const span = createElement('span'); // Type: HTMLSpanElement

Advanced Type Patterns

Generic Types

Use generics to create reusable, type-safe components and functions.

// Generic interface interface ApiResponse<T> { data: T; status: number; message: string; } // Generic function function identity<T>(arg: T): T { return arg; } // Generic class class Container<T> { private value: T; constructor(value: T) { this.value = value; } getValue(): T { return this.value; } setValue(value: T): void { this.value = value; } } // Usage const stringContainer = new Container<string>('hello'); const numberContainer = new Container<number>(42);

Conditional Types

Use conditional types to create dynamic type relationships.

// Conditional type type NonNullable<T> = T extends null | undefined ? never : T; // Mapped types type Readonly<T> = { readonly [P in keyof T]: T[P]; }; type Partial<T> = { [P in keyof T]?: T[P]; }; // Utility types type UserWithoutId = Omit<User, 'id'>; type UserOptional = Partial<User>; type UserReadonly = Readonly<User>;

Error Handling and Validation

Result Types

Use result types to handle errors in a type-safe way.

// Result type pattern type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; // Usage function divide(a: number, b: number): Result<number, string> { if (b === 0) { return { success: false, error: 'Division by zero' }; } return { success: true, data: a / b }; } // Handling results function handleDivision(a: number, b: number): void { const result = divide(a, b); if (result.success) { console.log('Result:', result.data); } else { console.error('Error:', result.error); } }

Input Validation

Use type guards and validation functions to ensure data integrity.

// Type guards function isUser(obj: any): obj is User { return ( typeof obj === 'object' && typeof obj.id === 'number' && typeof obj.name === 'string' && typeof obj.email === 'string' ); } // Validation function function validateUser(user: unknown): User { if (!isUser(user)) { throw new Error('Invalid user data'); } return user; } // Usage function processUserData(data: unknown): void { try { const user = validateUser(data); // user is now typed as User console.log(user.name); } catch (error) { console.error('Invalid user data:', error); } }

React and TypeScript

Component Props

Define clear, reusable prop types for React components.

// Component props interface interface ButtonProps { children: React.ReactNode; variant?: 'primary' | 'secondary' | 'danger'; size?: 'small' | 'medium' | 'large'; disabled?: boolean; onClick?: () => void; } // Functional component const Button: React.FC<ButtonProps> = ({ children, variant = 'primary', size = 'medium', disabled = false, onClick }) => { return ( <button className={`btn btn-${variant} btn-${size}`} disabled={disabled} onClick={onClick} > {children} </button> ); }; // Generic component interface ListProps<T> { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; keyExtractor: (item: T) => string | number; } function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return ( <ul> {items.map((item, index) => ( <li key={keyExtractor(item)}> {renderItem(item, index)} </li> ))} </ul> ); }

Custom Hooks

Create type-safe custom hooks with proper return types.

// Custom hook with proper typing interface UseCounterReturn { count: number; increment: () => void; decrement: () => void; reset: () => void; } function useCounter(initialValue: number = 0): UseCounterReturn { const [count, setCount] = useState(initialValue); const increment = useCallback(() => { setCount(prev => prev + 1); }, []); const decrement = useCallback(() => { setCount(prev => prev - 1); }, []); const reset = useCallback(() => { setCount(initialValue); }, [initialValue]); return { count, increment, decrement, reset }; } // Async hook interface UseApiReturn<T> { data: T | null; loading: boolean; error: Error | null; refetch: () => void; } function useApi<T>(url: string): UseApiReturn<T> { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null); const fetchData = useCallback(async () => { try { setLoading(true); setError(null); const response = await fetch(url); const result = await response.json(); setData(result); } catch (err) { setError(err instanceof Error ? err : new Error('Unknown error')); } finally { setLoading(false); } }, [url]); useEffect(() => { fetchData(); }, [fetchData]); return { data, loading, error, refetch: fetchData }; }

Common Anti-Patterns to Avoid

  • Using any:

    Avoid the any type as it defeats the purpose of TypeScript
  • Type Assertions:

    Use type guards instead of type assertions when possible
  • Optional Chaining Everywhere:

    Design your types to be explicit about what can be undefined
  • Complex Union Types:

    Break down complex unions into smaller, more manageable types
  • Ignoring Compiler Errors:

    Fix type errors instead of using type assertions or any
// Bad - Using any function processData(data: any): any { return data.map(item => item.value); } // Good - Proper typing function processData<T extends { value: unknown }>(data: T[]): T['value'][] { return data.map(item => item.value); } // Bad - Type assertion const user = response.data as User; // Good - Type guard if (isUser(response.data)) { const user = response.data; // Properly typed }

Configuration and Tooling

ESLint and Prettier

Configure ESLint and Prettier for consistent code style and catch potential issues.

// .eslintrc.js module.exports = { parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], extends: [ 'eslint:recommended', '@typescript-eslint/recommended', '@typescript-eslint/recommended-requiring-type-checking' ], rules: { '@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/explicit-function-return-type': 'warn', '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/prefer-interface': 'error' } }; // .prettierrc { "semi": true, "trailingComma": "es5", "singleQuote": true, "printWidth": 80, "tabWidth": 2 }

Path Mapping

Use path mapping to create cleaner imports and better project organization.

// tsconfig.json { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@/components/*": ["src/components/*"], "@/utils/*": ["src/utils/*"], "@/types/*": ["src/types/*"] } } } // Usage import { Button } from '@/components/Button'; import { formatDate } from '@/utils/date'; import { User } from '@/types/user';

Testing with TypeScript

Write type-safe tests using Jest and TypeScript.

// Test utilities interface TestUser { id: number; name: string; email: string; } const createTestUser = (overrides: Partial<TestUser> = {}): TestUser => ({ id: 1, name: 'Test User', email: 'test@example.com', ...overrides }); // Test with proper typing describe('UserService', () => { it('should create a user', async () => { const userData = createTestUser({ name: 'John Doe' }); const result = await createUser(userData); expect(result).toMatchObject(userData); expect(result.id).toBeDefined(); }); }); // Mock functions with proper types const mockFetch = jest.fn<Promise<Response>, [string]>(); beforeEach(() => { mockFetch.mockClear(); });

Performance Considerations

TypeScript can impact build performance. Here are some optimization tips:

  • Incremental Compilation:

    Enable incremental compilation for faster rebuilds
  • Project References:

    Use project references to split large codebases
  • Skip Lib Check:

    Skip library checking in development for faster builds
  • Exclude Files:

    Exclude unnecessary files from compilation

Best Practices Summary

  1. Use Strict Mode:

    Enable strict TypeScript configuration
  2. Prefer Interfaces:

    Use interfaces for object shapes, types for unions and primitives
  3. Explicit Return Types:

    Define return types for public APIs
  4. Avoid any:

    Use proper types instead of any
  5. Use Generics:

    Create reusable, type-safe components
  6. Type Guards:

    Use type guards for runtime type checking
  7. Error Handling:

    Use result types for type-safe error handling
  8. Consistent Naming:

    Use consistent naming conventions for types

Conclusion

TypeScript is a powerful tool that can significantly improve code quality and developer experience when used correctly. By following these best practices, you can write more maintainable, type-safe code that scales with your application.

Remember that TypeScript is a tool to help you write better code, not a replacement for good software engineering practices. Use it thoughtfully and always consider the trade-offs between type safety and code complexity.

© 2025 Tasnimul Mohammad Fahim. All Rights Reserved.