TypeScript Best Practices for Large-Scale Applications
TypeScript has become the language of choice for many teams building large-scale applications. Its static typing system helps catch errors early, improves code quality, and enhances developer productivity. However, as projects grow in size and complexity, it becomes crucial to adopt best practices to ensure maintainability and performance.
Type System Fundamentals
Leverage TypeScript's Type Inference
TypeScript's type inference is powerful. Use it when it makes your code more readable:
// Let TypeScript infer the type
const users = [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' }
];
// Instead of explicitly typing everything
const users: Array<{ id: number; name: string; role: string }> = [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' }
];
Use Explicit Types for Function Parameters and Return Types
Always provide explicit types for function parameters and return types to create clear contracts:
// Good
function calculateTotal(items: CartItem[]): number {
return items.reduce((total, item) => total + item.price * item.quantity, 0);
}
// Avoid
function calculateTotal(items) {
return items.reduce((total, item) => total + item.price * item.quantity, 0);
}
Utilize Union Types and Type Guards
Union types and type guards create more flexible and type-safe code:
type PaymentMethod = CreditCard | PayPal | BankTransfer;
function processPayment(payment: PaymentMethod): Receipt {
// Type guard
if ('cardNumber' in payment) {
// TypeScript knows payment is CreditCard here
return processCreditCardPayment(payment);
} else if ('email' in payment) {
// TypeScript knows payment is PayPal here
return processPayPalPayment(payment);
} else {
// TypeScript knows payment is BankTransfer here
return processBankTransferPayment(payment);
}
}
Project Structure
Organize by Feature, Not by Type
Structure your codebase around business features rather than technical types:
src/
features/
auth/
components/
hooks/
services/
types/
utils/
index.ts
products/
components/
hooks/
services/
types/
utils/
index.ts
shared/
components/
hooks/
services/
types/
utils/
Use Barrel Files to Simplify Imports
Create barrel files (index.ts
) to simplify import statements:
// features/auth/index.ts
export * from './components';
export * from './hooks';
export * from './services';
export * from './types';
// In another file
import { LoginForm, useAuth, AuthService, User } from 'features/auth';
Isolate Third-Party Dependencies
Isolate third-party dependencies behind adapters to make future changes easier:
// services/api/axios-adapter.ts
import axios from 'axios';
import type { HttpClient } from './types';
export const axiosHttpClient: HttpClient = {
get: (url, config) => axios.get(url, config).then(response => response.data),
post: (url, data, config) => axios.post(url, data, config).then(response => response.data),
// Other methods...
};
// Use the adapter in your services
import { axiosHttpClient as httpClient } from 'services/api';
Type Definitions
Create Strong Domain Types
Define strong domain types to represent your business entities:
type UserId = string;
type Email = string;
type Role = 'admin' | 'user' | 'guest';
interface User {
id: UserId;
email: Email;
name: string;
role: Role;
createdAt: Date;
}
Use Branded Types for Type Safety
Create "branded" or "nominal" types to distinguish between semantically different values with the same primitive type:
type UserId = string & { readonly _brand: unique symbol };
type OrderId = string & { readonly _brand: unique symbol };
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUserById(id: UserId) { /* ... */ }
// This will cause a type error
getUserById(createOrderId('123'));
Utilize Utility Types
Take advantage of TypeScript's utility types to transform existing types:
// Make all properties optional
type UserUpdate = Partial<User>;
// Pick specific properties
type UserCredentials = Pick<User, 'email' | 'password'>;
// Omit specific properties
type PublicUser = Omit<User, 'password' | 'securityQuestions'>;
// Extract property types
type UserRole = User['role'];
Error Handling and Nullability
Avoid Implicit any
Configure your tsconfig.json
to prevent implicit any
types:
{
"compilerOptions": {
"noImplicitAny": true
}
}
Use Discriminated Unions for Error Handling
Create discriminated unions for type-safe error handling:
type Result<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const user = await api.users.getById(id);
return { status: 'success', data: user };
} catch (error) {
return {
status: 'error',
error: error instanceof Error ? error : new Error(String(error))
};
}
}
const result = await fetchUser('123');
if (result.status === 'success') {
// TypeScript knows result.data is User
// Instead of console.log
// Process the user data
} else {
// TypeScript knows result.error is Error
// Instead of console.error
// Display error to user or retry the operation
}
Avoid Non-Null Assertion Operator When Possible
Minimize use of the non-null assertion operator (!
) and handle nullability explicitly:
// Avoid
function getUsername(user?: User): string {
return user!.name; // Dangerous if user is undefined
}
// Better
function getUsername(user?: User): string {
if (!user) {
throw new Error('User is required');
}
return user.name;
}
// Or even better
function getUsername(user?: User): string {
return user?.name ?? 'Anonymous';
}
Performance Considerations
Use readonly
for Immutable Data
Mark properties and arrays as readonly
when they shouldn't be modified:
interface Config {
readonly apiUrl: string;
readonly maxRetries: number;
readonly timeout: number;
}
function processItems(items: readonly Item[]): number {
// TypeScript will prevent mutating the array
return items.reduce((sum, item) => sum + item.value, 0);
}
Optimize Type Checking with type
vs interface
Use type
for unions, primitives, and tuples. Use interface
for objects that might be extended:
// Use type for unions
type Status = 'pending' | 'completed' | 'failed';
// Use interface for extensible objects
interface User {
id: string;
name: string;
}
// Can be extended
interface AdminUser extends User {
permissions: string[];
}
Limit Use of any
Avoid using any
as it bypasses type checking. Use unknown
instead when the type is truly unknown:
// Bad: Using any
function parseData(data: any) {
return data.someProperty; // No type safety
}
// Better: Using unknown with type guards
function parseData(data: unknown): string {
if (typeof data === 'object' && data !== null && 'someProperty' in data) {
return String(data.someProperty);
}
throw new Error('Invalid data format');
}
Testing
Write Type Tests with @typescript/expect-error
Use @ts-expect-error
to test that invalid types are caught:
function add(a: number, b: number): number {
return a + b;
}
// This should cause a compile-time error
// @ts-expect-error
add('1', '2');
Use Typesafe Testing Libraries
Choose testing libraries with good TypeScript support, like Jest with ts-jest
or Vitest:
import { expect, test } from 'vitest';
import { User } from '../types';
import { validateUser } from '../validation';
test('validateUser returns errors for invalid users', () => {
const invalidUser: Partial<User> = { name: '' };
const result = validateUser(invalidUser as User);
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Name is required');
});
Conclusion
Implementing these TypeScript best practices will help you build large-scale applications that are maintainable, robust, and developer-friendly. Remember that the goal of TypeScript isn't just to add types – it's to create a better developer experience and produce higher-quality code.
As your application grows, revisit these practices and adapt them to your team's specific needs. TypeScript is a powerful tool that can scale with your project, especially when you leverage its type system effectively.