TypeScript Best Practices: Writing Maintainable Enterprise Code
TypeScript has become the standard for large-scale JavaScript applications, and for good reason. But writing TypeScript that's truly maintainable requires more than just adding type annotations. After working with TypeScript in enterprise codebases for years, I've learned what works and what doesn't. Let me share the practices that have made the biggest difference.
Type Safety Without Over-Engineering
Use Strict Mode
Always enable strict mode in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Yes, it's more work initially, but it catches bugs at compile time that would surface in production.
Prefer Interfaces for Object Shapes
Use interfaces for object shapes, types for unions and intersections:
// Good: Interface for object shape
interface User {
id: string;
name: string;
email: string;
}
// Good: Type for union
type Status = 'pending' | 'approved' | 'rejected';
// Good: Type for complex types
type UserWithStatus = User & { status: Status };
Effective Type Definitions
Avoid any, Use unknown
any disables type checking. Use unknown when you truly don't know the type:
// Bad
function processData(data: any) {
return data.someProperty; // No type safety
}
// Good
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'someProperty' in data) {
return (data as { someProperty: string }).someProperty;
}
throw new Error('Invalid data');
}
Use Discriminated Unions
For better type narrowing:
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
function handleResult<T>(result: Result<T>) {
if (result.success) {
// TypeScript knows result.data exists here
console.log(result.data);
} else {
// TypeScript knows result.error exists here
console.error(result.error);
}
}
Generics: When and How
Use Generics for Reusability
// Good: Generic function
function getById<T extends { id: string }>(
items: T[],
id: string
): T | undefined {
return items.find(item => item.id === id);
}
// Usage
const user = getById(users, '123'); // Type: User | undefined
const product = getById(products, '456'); // Type: Product | undefined
Constrain Generics Appropriately
// Bad: Too permissive
function process<T>(item: T): T {
// Can't do much with T
}
// Good: Constrained
function process<T extends { id: string; status: string }>(
item: T
): T {
// Can safely access item.id and item.status
return { ...item, status: 'processed' };
}
Utility Types: Your Best Friends
Common Utility Types
// Partial: Make all properties optional
type PartialUser = Partial<User>;
// Pick: Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// Omit: Exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>;
// Record: Dictionary type
type UserMap = Record<string, User>;
// Readonly: Make properties readonly
type ImmutableUser = Readonly<User>;
Create Your Own Utility Types
// Make specific properties required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
type UserWithRequiredEmail = RequireFields<User, 'email'>;
// Make specific properties optional
type OptionalFields<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type UserWithOptionalEmail = OptionalFields<User, 'email'>;
Function Signatures
Explicit Return Types
Always specify return types for public functions:
// Good
function calculateTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
// Bad: Return type inferred (can change unexpectedly)
function calculateTotal(items: Item[]) {
return items.reduce((sum, item) => sum + item.price, 0);
}
Use Function Overloads Sparingly
// Good: Clear overloads
function format(value: string): string;
function format(value: number): string;
function format(value: string | number): string {
return String(value);
}
// Bad: Too many overloads (use union types instead)
function process(value: string): string;
function process(value: number): number;
function process(value: boolean): boolean;
// ... 10 more overloads
Error Handling
Use Result Types Instead of Exceptions
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const user = await api.getUser(id);
return { ok: true, value: user };
} catch (error) {
return { ok: false, error: error as Error };
}
}
// Usage
const result = await fetchUser('123');
if (result.ok) {
console.log(result.value); // Type-safe access
} else {
console.error(result.error);
}
Class Design
Prefer Composition Over Inheritance
// Bad: Deep inheritance
class Animal {}
class Mammal extends Animal {}
class Dog extends Mammal {}
// Good: Composition
interface Flyable {
fly(): void;
}
interface Swimmable {
swim(): void;
}
class Duck {
private flyable: Flyable;
private swimmable: Swimmable;
constructor(flyable: Flyable, swimmable: Swimmable) {
this.flyable = flyable;
this.swimmable = swimmable;
}
}
Use Private Fields
class UserService {
private apiKey: string; // Truly private
#internalCache: Map<string, any>; // Also private
constructor(apiKey: string) {
this.apiKey = apiKey;
this.#internalCache = new Map();
}
}
Async Patterns
Proper Async/Await Usage
// Good: Sequential when order matters
async function processOrder(order: Order) {
const user = await getUser(order.userId);
const inventory = await checkInventory(order.items);
const payment = await processPayment(order);
return { user, inventory, payment };
}
// Good: Parallel when order doesn't matter
async function processOrder(order: Order) {
const [user, inventory, payment] = await Promise.all([
getUser(order.userId),
checkInventory(order.items),
processPayment(order)
]);
return { user, inventory, payment };
}
Type-Safe Promises
// Good: Explicit Promise types
async function fetchData(): Promise<Data> {
const response = await fetch('/api/data');
return response.json();
}
// Good: Promise utility type
type AsyncResult<T> = Promise<Result<T>>;
async function safeFetch<T>(url: string): AsyncResult<T> {
try {
const response = await fetch(url);
const data = await response.json();
return { ok: true, value: data };
} catch (error) {
return { ok: false, error: error as Error };
}
}
Testing with TypeScript
Type-Safe Test Utilities
// Test helper with proper types
function createMockUser(overrides?: Partial<User>): User {
return {
id: '1',
name: 'Test User',
email: 'test@example.com',
...overrides
};
}
// Usage
const user = createMockUser({ name: 'Custom Name' });
Type Guards in Tests
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
'email' in obj
);
}
test('returns valid user', () => {
const result = getUser('123');
expect(isUser(result)).toBe(true);
if (isUser(result)) {
expect(result.name).toBeDefined(); // Type-safe
}
});
Common Anti-Patterns to Avoid
1. Type Assertions as a Crutch
// Bad: Using 'as' to silence errors
const user = data as User; // Dangerous!
// Good: Proper type checking
if (isUser(data)) {
const user = data; // Type-safe
}
2. Optional Chaining Everywhere
// Bad: Hiding potential issues
const name = user?.profile?.details?.name ?? 'Unknown';
// Good: Handle the case properly
if (user?.profile?.details?.name) {
const name = user.profile.details.name;
// Use name
} else {
// Handle missing name
}
3. Ignoring Compiler Warnings
Fix warnings, don't suppress them. They're usually pointing to real issues.
Project Organization
Barrel Exports
// types/index.ts
export type { User, Product, Order } from './entities';
export type { ApiResponse, PaginatedResponse } from './api';
export type { Result, Option } from './utils';
Separate Types from Implementation
// types/user.ts
export interface User {
id: string;
name: string;
}
// services/user.ts
import type { User } from '../types/user';
export class UserService {
async getUser(id: string): Promise<User> {
// Implementation
}
}
Conclusion
TypeScript is powerful, but with great power comes great responsibility. The key to maintainable TypeScript code is:
- Use strict mode - Catch errors early
- Prefer types over assertions - Let TypeScript do its job
- Create reusable types - DRY applies to types too
- Document complex types - Future you will thank you
- Refactor incrementally - You don't need to type everything at once
Remember: TypeScript is a tool to help you write better code, not a goal in itself. Focus on type safety that provides real value, not type safety for its own sake.
What TypeScript patterns have you found most useful in your projects? I'd love to hear about your experiences.
Related Posts
The Art of Code Refactoring: When and How to Refactor Legacy Code
Learn the art and science of refactoring legacy code. Discover when to refactor, how to do it safely, and techniques that have transformed unmaintainable codebases.
Documentation Best Practices: Writing Code That Documents Itself
Learn how to write effective documentation that helps your team understand and maintain code. From code comments to API docs, master the art of clear communication.
Code Review Best Practices: How to Review Code Effectively
Master the art of code review. Learn how to provide constructive feedback, catch bugs early, and improve code quality through effective peer review.
Managing Technical Debt: A Pragmatic Approach
Learn how to identify, prioritize, and manage technical debt effectively. A practical guide to keeping your codebase healthy without slowing down development.
Testing Strategies for Modern Web Applications
Learn comprehensive testing strategies that ensure your web applications are reliable, maintainable, and bug-free. From unit tests to E2E testing.
Code Review Best Practices: A Senior Engineer's Guide
Learn how to conduct effective code reviews that improve code quality, share knowledge, and build stronger teams. Practical advice from years of reviewing code.