TypeScript Best Practices
This guide covers essential TypeScript best practices to help you write clean, maintainable, and type-safe code. Following these practices will improve code quality and developer experience.
Project Configuration
Enable Strict Mode
Always enable strict
mode in your tsconfig.json
for maximum type safety:
// tsconfig.json
{
"compilerOptions": {
/* Enable all strict type-checking options */
"strict": true,
/* Additional recommended settings */
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
Enable Strict Checks
Consider enabling these additional strict checks for better code quality:
{
"compilerOptions": {
/* Additional strict checks */
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
Type System Best Practices
Use Type Inference Where Possible
Let TypeScript infer types when the type is obvious from the assignment:
// Bad: Redundant type annotation
const name: string = 'John';
// Good: Let TypeScript infer the type
const name = 'John';
// Bad: Redundant return type
function add(a: number, b: number): number {
return a + b;
}
// Good: Let TypeScript infer return type
function add(a: number, b: number) {
return a + b;
}
Try it Yourself »
Precise Type Annotations
Be explicit with types for public APIs and function parameters:
// Bad: No type information
function processUser(user) {
return user.name.toUpperCase();
}
// Good: Explicit parameter and return types
interface User {
id: number;
name: string;
email?: string; // Optional property
}
function processUser(user: User): string {
return user.name.toUpperCase();
}
Interfaces vs. Type Aliases
Know when to use interface
vs type
:
// Use interface for object shapes that can be extended/implemented
interface User {
id: number;
name: string;
}
// Extending an interface
interface AdminUser extends User {
permissions: string[];
}
// Use type for unions, tuples, or mapped types
type UserRole = 'admin' | 'editor' | 'viewer';
// Union types
type UserId = number | string;
// Mapped types
type ReadonlyUser = Readonly<User>;
// Tuple types
type Point = [number, number];
Try it Yourself »
Avoid any
Type
Prefer more specific types over any
:
// Bad: Loses type safety
function logValue(value: any) {
console.log(value.toUpperCase()); // No error until runtime
}
// Better: Use generic type parameter
function logValue<T>(value: T) {
console.log(String(value)); // Safer, but still not ideal
}
// Best: Be specific about expected types
function logString(value: string) {
console.log(value.toUpperCase()); // Type-safe
}
// When you need to accept any value but still be type-safe
function logUnknown(value: unknown) {
if (typeof value === 'string') {
console.log(value.toUpperCase());
} else {
console.log(String(value));
}
}
Code Organization
Module Organization
Organize code into logical modules with clear responsibilities:
// user/user.model.ts
export interface User {
id: string;
name: string;
email: string;
}
// user/user.service.ts
import { User } from './user.model';
export class UserService {
private users: User[] = [];
addUser(user: User) {
this.users.push(user);
}
getUser(id: string): User | undefined {
return this.users.find(user => user.id === id);
}
}
// user/index.ts (barrel file)
export * from './user.model';
export * from './user.service';
File Naming Conventions
Follow consistent file naming patterns:
// Good
user.service.ts // Service classes
user.model.ts // Type definitions
user.controller.ts // Controllers
user.component.ts // Components
user.utils.ts // Utility functions
user.test.ts // Test files
// Bad
UserService.ts // Avoid PascalCase for file names
user_service.ts // Avoid snake_case
userService.ts // Avoid camelCase for file names
Best Practices
- Document your types and interfaces.
- Prefer composition over inheritance for types.
- Keep
tsconfig.json
strict and up-to-date. - Refactor code to use more specific types as the codebase evolves.
Functions and Methods
Function Parameters and Return Types
Write clear and type-safe functions with proper parameter and return types:
// Bad: No type information
function process(user, notify) {
notify(user.name);
}
// Good: Explicit parameter and return types
function processUser(
user: User,
notify: (message: string) => void
): void {
notify(`Processing user: ${user.name}`);
}
// Use default parameters instead of conditionals
function createUser(
name: string,
role: UserRole = 'viewer',
isActive: boolean = true
): User {
return { name, role, isActive };
}
// Use rest parameters for variable arguments
function sum(...numbers: number[]): number {
return numbers.reduce((total, num) => total + num, 0);
}
Try it Yourself »
Avoid Function Overuse
Be mindful of function complexity and responsibilities:
// Bad: Too many responsibilities
function processUserData(userData: any) {
// Validation
if (!userData || !userData.name) throw new Error('Invalid user data');
// Data transformation
const processedData = {
...userData,
name: userData.name.trim(),
createdAt: new Date()
};
// Side effect
saveToDatabase(processedData);
// Notification
sendNotification(processedData.email, 'Profile updated');
return processedData;
}
// Better: Split into smaller, focused functions
function validateUserData(data: unknown): UserData {
if (!data || typeof data !== 'object') {
throw new Error('Invalid user data');
}
return data as UserData;
}
function processUserData(userData: UserData): ProcessedUserData {
return {
...userData,
name: userData.name.trim(),
createdAt: new Date()
};
}
Async/Await Patterns
Proper Async/Await Usage
Handle asynchronous operations effectively with proper error handling:
// Bad: Not handling errors
async function fetchData() {
const response = await fetch('/api/data');
return response.json();
}
// Good: Proper error handling
async function fetchData<T>(url: string): Promise<T> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json() as T;
} catch (error) {
console.error('Failed to fetch data:', error);
throw error; // Re-throw to allow caller to handle
}
}
// Better: Use Promise.all for parallel operations
async function fetchMultipleData<T>(urls: string[]): Promise<T[]> {
try {
const promises = urls.map(url => fetchData<T>(url));
return await Promise.all(promises);
} catch (error) {
console.error('One or more requests failed:', error);
throw error;
}
}
// Example usage
interface User {
id: string;
name: string;
email: string;
}
// Fetch user data with proper typing
async function getUserData(userId: string): Promise<User> {
return fetchData<User>(`/api/users/${userId}`);
}
Avoid Nested Async/Await
Flatten your async/await code to avoid callback hell:
// Bad: Nested async/await (callback hell)
async function processUser(userId: string) {
const user = await getUser(userId);
if (user) {
const orders = await getOrders(user.id);
if (orders.length > 0) {
const latestOrder = orders[0];
const items = await getOrderItems(latestOrder.id);
return { user, latestOrder, items };
}
}
return null;
}
// Better: Flatten the async/await chain
async function processUser(userId: string) {
const user = await getUser(userId);
if (!user) return null;
const orders = await getOrders(user.id);
if (orders.length === 0) return { user, latestOrder: null, items: [] };
const latestOrder = orders[0];
const items = await getOrderItems(latestOrder.id);
return { user, latestOrder, items };
}
// Best: Use Promise.all for independent async operations
async function processUser(userId: string) {
const [user, orders] = await Promise.all([
getUser(userId),
getOrders(userId)
]);
if (!user) return null;
if (orders.length === 0) return { user, latestOrder: null, items: [] };
const latestOrder = orders[0];
const items = await getOrderItems(latestOrder.id);
return { user, latestOrder, items };
}
Testing and Quality
Writing Testable Code
Design your code with testability in mind by using dependency injection and pure functions:
// Bad: Hard to test due to direct dependencies
class PaymentProcessor {
async processPayment(amount: number) {
const paymentGateway = new PaymentGateway();
return paymentGateway.charge(amount);
}
}
// Better: Use dependency injection
interface PaymentGateway {
charge(amount: number): Promise<boolean>;
}
class PaymentProcessor {
constructor(private paymentGateway: PaymentGateway) {}
async processPayment(amount: number): Promise<boolean> {
if (amount <= 0) {
throw new Error('Amount must be greater than zero');
}
return this.paymentGateway.charge(amount);
}
}
// Test example with Jest
describe('PaymentProcessor', () => {
let processor: PaymentProcessor;
let mockGateway: jest.Mocked<PaymentGateway>;
beforeEach(() => {
mockGateway = {
charge: jest.fn()
};
processor = new PaymentProcessor(mockGateway);
});
it('should process a valid payment', async () => {
mockGateway.charge.mockResolvedValue(true);
const result = await processor.processPayment(100);
expect(result).toBe(true);
expect(mockGateway.charge).toHaveBeenCalledWith(100);
});
it('should throw for invalid amount', async () => {
await expect(processor.processPayment(-50))
.rejects
toThrow('Amount must be greater than zero');
});
});
Type Testing
Test your types to ensure they work as expected using type assertions and utilities:
// Using @ts-expect-error to test for type errors
// @ts-expect-error - Should not allow negative values
const invalidUser: User = { id: -1, name: 'Test' };
// Using type assertions in tests
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Not a string');
}
}
// Using utility types for testing
type IsString<T> = T extends string ? true : false;
type Test1 = IsString<string>; // true
type Test2 = IsString<number>; // false
// Using tsd for type testing (install with: npm install --save-dev tsd)
/*
import { expectType } from 'tsd';
const user = { id: 1, name: 'John' };
expectType<{ id: number; name: string }>(user);
expectType<string>(user.name);
*/
Performance Considerations
Type-Only Imports and Exports
Use type-only imports and exports to reduce bundle size and improve tree-shaking:
// Bad: Imports both type and value
import { User, fetchUser } from './api';
// Good: Separate type and value imports
import type { User } from './api';
import { fetchUser } from './api';
// Even better: Use type-only imports when possible
import type { User, UserSettings } from './types';
// Type-only export
export type { User };
// Runtime export
export { fetchUser };
// In tsconfig.json, enable "isolatedModules": true
// to ensure type-only imports are properly handled
Avoid Excessive Type Complexity
Be mindful of complex types that can impact compilation time:
// Bad: Deeply nested mapped types can be slow
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Better: Use built-in utility types when possible
type User = {
id: string;
profile: {
name: string;
email: string;
};
preferences?: {
notifications: boolean;
};
};
// Instead of DeepPartial<User>, use Partial with type assertions
const updateUser = (updates: Partial<User>) => {
// Implementation
};
// For complex types, consider using interfaces
interface UserProfile {
name: string;
email: string;
}
interface UserPreferences {
notifications: boolean;
}
interface User {
id: string;
profile: UserProfile;
preferences?: UserPreferences;
}
Use const Assertions for Literal Types
Improve type inference and performance with const assertions:
// Without const assertion (wider type)
const colors = ['red', 'green', 'blue'];
// Type: string[]
// With const assertion (narrower, more precise type)
const colors = ['red', 'green', 'blue'] as const;
// Type: readonly ["red", "green", "blue"]
// Extract union type from const array
type Color = typeof colors[number]; // "red" | "green" | "blue"
// Objects with const assertions
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
features: ['auth', 'notifications'],
} as const;
// Type is:
// {
// readonly apiUrl: "https://api.example.com";
// readonly timeout: 5000;
// readonly features: readonly ["auth", "notifications"];
// }
Try it Yourself »
Common Mistakes to Avoid
Overusing the any
Type
Avoid using `any` as it defeats TypeScript's type checking:
// Bad: Loses all type safety
function process(data: any) {
return data.map(item => item.name);
}
// Better: Use generics for type safety
function process<T extends { name: string }>(items: T[]) {
return items.map(item => item.name);
}
// Best: Use specific types when possible
interface User {
name: string;
age: number;
}
function processUsers(users: User[]) {
return users.map(user => user.name);
}
Not Using Strict Mode
Always enable strict mode in your `tsconfig.json`:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
/* Additional strictness flags */
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
Ignoring Type Inference
Let TypeScript infer types when possible:
// Redundant type annotation
const name: string = 'John';
// Let TypeScript infer the type
const name = 'John'; // TypeScript knows it's a string
// Redundant return type
function add(a: number, b: number): number {
return a + b;
}
// Let TypeScript infer the return type
function add(a: number, b: number) {
return a + b; // TypeScript infers number
}
Not Using Type Guards
Use type guards to narrow types safely:
// Without type guard
function process(input: string | number) {
return input.toUpperCase(); // Error: toUpperCase doesn't exist on number
}
// With type guard
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function process(input: string | number) {
if (isString(input)) {
return input.toUpperCase(); // TypeScript knows input is string here
} else {
return input.toFixed(2); // TypeScript knows input is number here
}
}
// Built-in type guards
if (typeof value === 'string') { /* value is string */ }
if (value instanceof Date) { /* value is Date */ }
if ('id' in user) { /* user has id property */ }
Try it Yourself »
Not Handling null
and undefined
Always handle potential `null` or `undefined` values:
// Bad: Potential runtime error
function getLength(str: string | null) {
return str.length; // Error: Object is possibly 'null'
}
// Good: Null check
function getLength(str: string | null) {
if (str === null) return 0;
return str.length;
}
// Better: Use optional chaining and nullish coalescing
function getLength(str: string | null) {
return str?.length ?? 0;
}
// For arrays
const names: string[] | undefined = [];
const count = names?.length ?? 0; // Safely handle undefined
// For object properties
interface User {
profile?: {
name?: string;
};
}
const user: User = {};
const name = user.profile?.name ?? 'Anonymous';