TypeScript Best Practices for Large-Scale Applications
Learn essential TypeScript patterns and practices for building maintainable, scalable applications with better type safety and developer experience.
TypeScript Best Practices for Large-Scale Applications
TypeScript has become the de facto standard for building robust JavaScript applications. Here are essential best practices for leveraging TypeScript effectively in large-scale projects.
Type Safety Fundamentals
Strict Configuration
Always enable strict mode in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
Prefer Type Assertions Over Any
Instead of using any, use type assertions when you know the type:
// ❌ Avoid
const data = response as any;
// ✅ Better
interface ApiResponse {
id: number;
name: string;
email: string;
}
const data = response as ApiResponse;
// ✅ Even better with validation
function isApiResponse(obj: unknown): obj is ApiResponse {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
'email' in obj
);
}
if (isApiResponse(response)) {
// TypeScript knows response is ApiResponse here
console.log(response.name);
}
Advanced Type Patterns
Utility Types for Better APIs
// Create flexible API interfaces
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
// For creating users (omit generated fields)
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// For updating users (make all fields optional except id)
type UpdateUserInput = Partial<Omit<User, 'id'>> & Pick<User, 'id'>;
// For public user data (omit sensitive fields)
type PublicUser = Omit<User, 'password'>;
Discriminated Unions for State Management
type LoadingState = {
status: 'loading';
};
type SuccessState = {
status: 'success';
data: User[];
};
type ErrorState = {
status: 'error';
error: string;
};
type AppState = LoadingState | SuccessState | ErrorState;
function handleState(state: AppState) {
switch (state.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={state.data} />; // TypeScript knows data exists
case 'error':
return <ErrorMessage error={state.error} />; // TypeScript knows error exists
}
}
Generic Constraints for Flexible APIs
// Constrain generics for better type safety
interface Identifiable {
id: string | number;
}
function updateEntity<T extends Identifiable>(
entities: T[],
id: T['id'],
updates: Partial<Omit<T, 'id'>>
): T[] {
return entities.map(entity =>
entity.id === id ? { ...entity, ...updates } : entity
);
}
// Usage
const users = updateEntity(userList, 123, { name: 'John Doe' });
const products = updateEntity(productList, 'abc', { price: 29.99 });
Error Handling Patterns
Result Type Pattern
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function fetchUser(id: number): Promise<Result<User, string>> {
try {
const response = await api.get(`/users/${id}`);
return { success: true, data: response.data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
// Usage
const result = await fetchUser(123);
if (result.success) {
console.log(result.data.name); // TypeScript knows data exists
} else {
console.error(result.error); // TypeScript knows error exists
}
Custom Error Classes
abstract class AppError extends Error {
abstract readonly statusCode: number;
abstract readonly isOperational: boolean;
constructor(message: string, public readonly context?: Record<string, unknown>) {
super(message);
this.name = this.constructor.name;
}
}
class ValidationError extends AppError {
readonly statusCode = 400;
readonly isOperational = true;
constructor(message: string, public readonly field: string) {
super(message, { field });
}
}
class NotFoundError extends AppError {
readonly statusCode = 404;
readonly isOperational = true;
constructor(resource: string, id: string | number) {
super(`${resource} with id ${id} not found`, { resource, id });
}
}
Module Organization
Barrel Exports
// types/index.ts
export type { User, CreateUserInput, UpdateUserInput } from './user';
export type { Product, CreateProductInput } from './product';
export type { ApiResponse, PaginatedResponse } from './api';
// services/index.ts
export { UserService } from './user-service';
export { ProductService } from './product-service';
export { ApiClient } from './api-client';
// Usage
import { User, UserService } from '@/types';
import { ApiClient } from '@/services';
Namespace Organization
namespace API {
export namespace Users {
export interface GetUsersParams {
page?: number;
limit?: number;
search?: string;
}
export interface CreateUserPayload {
name: string;
email: string;
password: string;
}
}
export namespace Products {
export interface GetProductsParams {
category?: string;
minPrice?: number;
maxPrice?: number;
}
}
}
// Usage
function getUsers(params: API.Users.GetUsersParams) {
// Implementation
}
Performance Considerations
Lazy Loading with Dynamic Imports
// Define the module type
type ChartModule = typeof import('./chart-component');
class DashboardComponent {
private chartModule: ChartModule | null = null;
async loadChart() {
if (!this.chartModule) {
this.chartModule = await import('./chart-component');
}
return this.chartModule;
}
async renderChart(data: ChartData) {
const { ChartComponent } = await this.loadChart();
return new ChartComponent(data);
}
}
Type-Only Imports
// Only import types, not runtime code
import type { User } from './types/user';
import type { ApiResponse } from './types/api';
// This won't be included in the bundle
export function processUser(user: User): ApiResponse<User> {
// Implementation
}
Testing with TypeScript
Type-Safe Test Utilities
// Test utilities with proper typing
export function createMockUser(overrides: Partial<User> = {}): User {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
password: 'password',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Type-safe API mocking
export function mockApiResponse<T>(data: T): ApiResponse<T> {
return {
success: true,
data,
message: 'Success',
};
}
// Usage in tests
const mockUser = createMockUser({ name: 'John Doe' });
const mockResponse = mockApiResponse(mockUser);
Conclusion
TypeScript’s power lies not just in catching errors, but in enabling better software design through its type system. By following these practices, you can build more maintainable, scalable applications that are easier to refactor and extend.
Remember that TypeScript is a tool to enhance your development experience - use it to express your intent clearly and catch errors early, but don’t let it become a burden that slows down development.