TypeScript Best Practices: Writing Maintainable Code
July 1, 2025 • 14 min read

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 TypeScriptType Assertions:
Use type guards instead of type assertions when possibleOptional Chaining Everywhere:
Design your types to be explicit about what can be undefinedComplex Union Types:
Break down complex unions into smaller, more manageable typesIgnoring 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 rebuildsProject References:
Use project references to split large codebasesSkip Lib Check:
Skip library checking in development for faster buildsExclude Files:
Exclude unnecessary files from compilation
Best Practices Summary
Use Strict Mode:
Enable strict TypeScript configurationPrefer Interfaces:
Use interfaces for object shapes, types for unions and primitivesExplicit Return Types:
Define return types for public APIsAvoid any:
Use proper types instead of anyUse Generics:
Create reusable, type-safe componentsType Guards:
Use type guards for runtime type checkingError Handling:
Use result types for type-safe error handlingConsistent 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.