TypeScript Best Practices
Best practices ensure maintainable, scalable TypeScript code. This article covers organization, patterns, and production guidelines.
Introduction
Best practices provide:
- Code quality
- Maintainability
- Scalability
- Performance
- Team collaboration
Understanding best practices helps you:
- Write better code
- Avoid common pitfalls
- Build scalable systems
- Improve team productivity
- Reduce technical debt
Type Safety
Strict Mode
// โ
Good: Enable strict mode
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
// โ
Good: Explicit types
const user: { name: string; age: number } = {
name: 'John',
age: 30
};
// โ Bad: Implicit any
const user: any = { name: 'John', age: 30 };
Null and Undefined Handling
// โ
Good: Handle null/undefined
function processUser(user: User | null | undefined): void {
if (!user) {
console.log('No user provided');
return;
}
console.log(user.name);
}
// โ
Good: Use optional chaining
const name = user?.name;
const email = user?.contact?.email;
// โ
Good: Use nullish coalescing
const port = config.port ?? 3000;
const timeout = config.timeout ?? 5000;
// โ Bad: Assume non-null
function processUser(user: User): void {
console.log(user.name); // May crash if user is null
}
Code Organization
Project Structure
// โ
Good: Organized project structure
/*
src/
โโโ types/
โ โโโ index.ts
โ โโโ user.ts
โ โโโ api.ts
โโโ interfaces/
โ โโโ index.ts
โ โโโ IRepository.ts
โโโ classes/
โ โโโ User.ts
โ โโโ UserService.ts
โโโ utils/
โ โโโ index.ts
โ โโโ validators.ts
โ โโโ helpers.ts
โโโ services/
โ โโโ index.ts
โ โโโ UserService.ts
โ โโโ ApiService.ts
โโโ middleware/
โ โโโ auth.ts
โโโ config/
โ โโโ index.ts
โโโ index.ts
*/
// โ
Good: Barrel exports
// src/types/index.ts
export type { User } from './user';
export type { Post } from './post';
export type { ApiResponse } from './api';
// Usage
import type { User, Post } from '@/types';
Naming Conventions
// โ
Good: Clear naming
interface IUserRepository {
getUser(id: number): Promise<User>;
}
class UserService {
private userRepository: IUserRepository;
async getUserById(id: number): Promise<User> {
return this.userRepository.getUser(id);
}
}
// โ
Good: Consistent naming
const MAX_RETRIES = 3;
const DEFAULT_TIMEOUT = 5000;
const API_BASE_URL = 'https://api.example.com';
// โ Bad: Inconsistent naming
const maxRetries = 3;
const default_timeout = 5000;
const apiBaseUrl = 'https://api.example.com';
Error Handling
Custom Error Classes
// โ
Good: Custom error classes
class ApplicationError extends Error {
constructor(
message: string,
public statusCode: number = 500,
public details?: any
) {
super(message);
this.name = 'ApplicationError';
}
}
class ValidationError extends ApplicationError {
constructor(message: string, details?: any) {
super(message, 400, details);
this.name = 'ValidationError';
}
}
class NotFoundError extends ApplicationError {
constructor(resource: string) {
super(`${resource} not found`, 404);
this.name = 'NotFoundError';
}
}
// โ
Good: Error handling
async function getUser(id: number): Promise<User> {
if (!id || id <= 0) {
throw new ValidationError('Invalid user ID');
}
const user = await database.findUser(id);
if (!user) {
throw new NotFoundError('User');
}
return user;
}
Error Handling Patterns
// โ
Good: Try-catch with type guards
async function handleRequest(req: Request): Promise<Response> {
try {
const data = await processRequest(req);
return { status: 200, data };
} catch (error) {
if (error instanceof ValidationError) {
return { status: 400, error: error.message };
}
if (error instanceof NotFoundError) {
return { status: 404, error: error.message };
}
if (error instanceof Error) {
return { status: 500, error: error.message };
}
return { status: 500, error: 'Unknown error' };
}
}
// โ
Good: Result type pattern
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function createResult<T>(value: T): Result<T> {
return { ok: true, value };
}
function createError<E>(error: E): Result<never, E> {
return { ok: false, error };
}
async function getUser(id: number): Promise<Result<User>> {
try {
const user = await database.findUser(id);
return createResult(user);
} catch (error) {
return createError(error);
}
}
Testing
Type-Safe Testing
// โ
Good: Type-safe tests
import { describe, it, expect } from '@jest/globals';
describe('UserService', () => {
let userService: UserService;
let mockRepository: jest.Mocked<IUserRepository>;
beforeEach(() => {
mockRepository = {
getUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
deleteUser: jest.fn()
};
userService = new UserService(mockRepository);
});
it('should get user by id', async () => {
const user: User = { id: 1, name: 'John', email: '[email protected]' };
mockRepository.getUser.mockResolvedValue(user);
const result = await userService.getUserById(1);
expect(result).toEqual(user);
expect(mockRepository.getUser).toHaveBeenCalledWith(1);
});
it('should throw NotFoundError when user not found', async () => {
mockRepository.getUser.mockResolvedValue(null);
await expect(userService.getUserById(999)).rejects.toThrow(NotFoundError);
});
});
Performance
Optimization Patterns
// โ
Good: Lazy loading
class DataService {
private cache: Map<number, User> = new Map();
async getUser(id: number): Promise<User> {
if (this.cache.has(id)) {
return this.cache.get(id)!;
}
const user = await this.fetchUser(id);
this.cache.set(id, user);
return user;
}
private async fetchUser(id: number): Promise<User> {
// Fetch from API
return { id, name: 'John', email: '[email protected]' };
}
}
// โ
Good: Memoization
function memoize<T extends (...args: any[]) => any>(fn: T): T {
const cache = new Map();
return ((...args: any[]) => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn(...args);
cache.set(key, result);
return result;
}) as T;
}
// โ
Good: Batch operations
async function getUsersBatch(ids: number[]): Promise<User[]> {
const uniqueIds = [...new Set(ids)];
return Promise.all(uniqueIds.map(id => getUser(id)));
}
Dependency Injection
DI Pattern
// โ
Good: Dependency injection
interface IUserRepository {
getUser(id: number): Promise<User>;
}
interface IEmailService {
sendEmail(to: string, subject: string, body: string): Promise<void>;
}
class UserService {
constructor(
private userRepository: IUserRepository,
private emailService: IEmailService
) {}
async createUser(data: CreateUserData): Promise<User> {
const user = await this.userRepository.createUser(data);
await this.emailService.sendEmail(
user.email,
'Welcome',
'Welcome to our service'
);
return user;
}
}
// โ
Good: Container pattern
class Container {
private services: Map<string, any> = new Map();
register<T>(name: string, factory: () => T): void {
this.services.set(name, factory);
}
get<T>(name: string): T {
const factory = this.services.get(name);
if (!factory) {
throw new Error(`Service ${name} not found`);
}
return factory();
}
}
const container = new Container();
container.register('userRepository', () => new UserRepository());
container.register('emailService', () => new EmailService());
container.register('userService', () => {
return new UserService(
container.get('userRepository'),
container.get('emailService')
);
});
Documentation
JSDoc Comments
// โ
Good: Comprehensive JSDoc
/**
* Fetches a user by ID
* @param id - The user ID
* @returns The user object
* @throws {NotFoundError} If user not found
* @example
* const user = await getUser(1);
*/
async function getUser(id: number): Promise<User> {
// Implementation
}
/**
* Validates email format
* @param email - Email to validate
* @returns True if valid, false otherwise
*/
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
/**
* User service for managing users
* @class
*/
class UserService {
/**
* Creates a new user
* @param data - User data
* @returns Created user
*/
async createUser(data: CreateUserData): Promise<User> {
// Implementation
}
}
Production Patterns
Environment Configuration
// โ
Good: Environment-based configuration
interface Config {
nodeEnv: 'development' | 'production' | 'test';
port: number;
databaseUrl: string;
apiKey: string;
logLevel: 'debug' | 'info' | 'warn' | 'error';
}
function loadConfig(): Config {
const nodeEnv = (process.env.NODE_ENV || 'development') as Config['nodeEnv'];
const config: Config = {
nodeEnv,
port: parseInt(process.env.PORT || '3000', 10),
databaseUrl: process.env.DATABASE_URL || 'postgresql://localhost/db',
apiKey: process.env.API_KEY || '',
logLevel: (process.env.LOG_LEVEL || 'info') as Config['logLevel']
};
if (!config.apiKey && nodeEnv === 'production') {
throw new Error('API_KEY is required in production');
}
return config;
}
const config = loadConfig();
Logging
// โ
Good: Structured logging
interface LogEntry {
timestamp: string;
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
context?: Record<string, any>;
}
class Logger {
private level: number;
constructor(level: string = 'info') {
this.level = { debug: 0, info: 1, warn: 2, error: 3 }[level] || 1;
}
private log(level: string, message: string, context?: any): void {
const levelNum = { debug: 0, info: 1, warn: 2, error: 3 }[level] || 1;
if (levelNum < this.level) return;
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level: level as any,
message,
context
};
console.log(JSON.stringify(entry));
}
debug(message: string, context?: any): void {
this.log('debug', message, context);
}
info(message: string, context?: any): void {
this.log('info', message, context);
}
warn(message: string, context?: any): void {
this.log('warn', message, context);
}
error(message: string, context?: any): void {
this.log('error', message, context);
}
}
const logger = new Logger(config.logLevel);
Best Practices Summary
- Enable strict mode
- Use explicit types
- Handle null/undefined
- Organize code logically
- Use custom error classes
- Write type-safe tests
- Implement dependency injection
- Document with JSDoc
- Use environment configuration
- Implement structured logging
Common Mistakes
-
Using any:
// โ Bad const value: any = 'string'; // โ Good const value: string = 'string'; -
Not handling errors:
// โ Bad const user = await getUser(id); // โ Good try { const user = await getUser(id); } catch (error) { // Handle error } -
Tight coupling:
// โ Bad class UserService { private repository = new UserRepository(); } // โ Good class UserService { constructor(private repository: IUserRepository) {} }
Summary
Best practices ensure quality. Key takeaways:
- Enable strict mode
- Use explicit types
- Handle errors properly
- Organize code logically
- Implement dependency injection
- Write type-safe tests
- Document code
- Use environment configuration
- Implement logging
- Avoid common pitfalls
Related Resources
- TypeScript Handbook
- TypeScript Best Practices
- Clean Code in TypeScript
- TypeScript Deep Dive
- Effective TypeScript
Next Steps
- Review all TypeScript articles
- Practice TypeScript patterns
- Build TypeScript projects
- Contribute to TypeScript community
- Stay updated with TypeScript releases
Comments