Chapter 15: Best Practices & Patterns
Authored by syscook.dev
What are TypeScript Best Practices and Patterns?
TypeScript best practices and patterns are proven approaches, conventions, and design patterns that help developers write maintainable, scalable, and robust TypeScript code. These practices ensure code quality, consistency, and long-term maintainability.
Key Areas:
- Code Organization: Project structure and file organization
- Type Safety: Maximizing TypeScript's type system benefits
- Performance: Writing efficient TypeScript code
- Maintainability: Code that's easy to understand and modify
- Testing: Effective testing strategies
- Documentation: Clear and comprehensive documentation
Why Follow Best Practices?
1. Code Quality and Consistency
Best practices ensure consistent, high-quality code across projects and teams.
// Good: Consistent naming and structure
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<User>;
delete(id: string): Promise<void>;
}
class DatabaseUserRepository implements UserRepository {
constructor(private db: Database) {}
async findById(id: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE id = ?', [id]);
return result.rows[0] || null;
}
async save(user: User): Promise<User> {
await this.db.query(
'INSERT INTO users (id, name, email) VALUES (?, ?, ?)',
[user.id, user.name, user.email]
);
return user;
}
async delete(id: string): Promise<void> {
await this.db.query('DELETE FROM users WHERE id = ?', [id]);
}
}
// Bad: Inconsistent naming and structure
class userRepo {
async get(id: string) {
// Implementation
}
async create(user: any) {
// Implementation
}
}
2. Type Safety and Error Prevention
Best practices leverage TypeScript's type system to catch errors at compile time.
// Good: Strong typing
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
async function fetchUser(id: string): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
// Bad: Weak typing
async function fetchUser(id: any): Promise<any> {
const response = await fetch(`/api/users/${id}`);
return await response.json();
}
How to Apply Best Practices?
1. Code Organization
Project Structure
// Good: Clear project structure
src/
├── domain/
│ ├── entities/
│ │ ├── User.ts
│ │ └── Product.ts
│ ├── repositories/
│ │ ├── UserRepository.ts
│ │ └── ProductRepository.ts
│ └── services/
│ ├── UserService.ts
│ └── ProductService.ts
├── infrastructure/
│ ├── database/
│ │ ├── Database.ts
│ │ └── migrations/
│ └── external/
│ ├── EmailService.ts
│ └── PaymentService.ts
├── application/
│ ├── use-cases/
│ │ ├── CreateUserUseCase.ts
│ │ └── UpdateProductUseCase.ts
│ └── dto/
│ ├── CreateUserDTO.ts
│ └── UpdateProductDTO.ts
├── presentation/
│ ├── controllers/
│ │ ├── UserController.ts
│ │ └── ProductController.ts
│ ├── middleware/
│ │ ├── AuthMiddleware.ts
│ │ └── ValidationMiddleware.ts
│ └── routes/
│ ├── userRoutes.ts
│ └── productRoutes.ts
├── shared/
│ ├── types/
│ │ ├── common.ts
│ │ └── api.ts
│ ├── utils/
│ │ ├── helpers.ts
│ │ └── constants.ts
│ └── errors/
│ ├── AppError.ts
│ └── ValidationError.ts
└── main.ts
File Naming Conventions
// Good: Consistent file naming
// PascalCase for classes and interfaces
User.ts
UserService.ts
UserRepository.ts
// camelCase for utilities and functions
userHelpers.ts
apiClient.ts
validationUtils.ts
// kebab-case for components (React/Vue)
user-list.component.ts
user-form.component.ts
user-details.component.ts
// Bad: Inconsistent naming
user.ts
UserService.ts
user-repository.ts
UserHelpers.ts
2. Type Safety Best Practices
Strict Type Configuration
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
Proper Type Definitions
// Good: Specific and descriptive types
interface User {
readonly id: string;
name: string;
email: string;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
enum UserRole {
ADMIN = 'admin',
USER = 'user',
MODERATOR = 'moderator'
}
// Good: Generic types with constraints
interface Repository<T, K = string> {
findById(id: K): Promise<T | null>;
save(entity: T): Promise<T>;
delete(id: K): Promise<void>;
}
// Good: Utility types
type CreateUserRequest = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UpdateUserRequest = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
// Bad: Weak typing
interface User {
id: any;
name: any;
email: any;
role: any;
createdAt: any;
updatedAt: any;
}
Error Handling with Types
// Good: Typed error handling
class AppError extends Error {
constructor(
message: string,
public statusCode: number,
public isOperational: boolean = true
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message: string, public field: string) {
super(message, 400);
}
}
class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404);
}
}
// Good: Result type for error handling
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function createUser(userData: CreateUserRequest): Promise<Result<User, ValidationError | AppError>> {
try {
// Validation
if (!userData.email || !userData.email.includes('@')) {
return {
success: false,
error: new ValidationError('Invalid email format', 'email')
};
}
// Create user
const user = await userRepository.save({
...userData,
id: generateId(),
createdAt: new Date(),
updatedAt: new Date()
});
return { success: true, data: user };
} catch (error) {
return {
success: false,
error: error instanceof AppError ? error : new AppError('Unknown error', 500)
};
}
}
3. Performance Best Practices
Efficient Data Structures
// Good: Use appropriate data structures
class UserCache {
private users = new Map<string, User>(); // O(1) lookup
private emailIndex = new Map<string, string>(); // O(1) email lookup
addUser(user: User): void {
this.users.set(user.id, user);
this.emailIndex.set(user.email, user.id);
}
getUserById(id: string): User | undefined {
return this.users.get(id);
}
getUserByEmail(email: string): User | undefined {
const id = this.emailIndex.get(email);
return id ? this.users.get(id) : undefined;
}
}
// Good: Memoization for expensive calculations
function createMemoizedFunction<T extends (...args: any[]) => any>(
fn: T
): T {
const cache = new Map<string, ReturnType<T>>();
return ((...args: Parameters<T>) => {
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;
}
// Usage
const expensiveCalculation = createMemoizedFunction((n: number) => {
console.log(`Calculating for ${n}`);
return n * n * n;
});
Lazy Loading and Code Splitting
// Good: Lazy loading for large modules
const LazyComponent = React.lazy(() => import('./LazyComponent'));
// Good: Dynamic imports for code splitting
async function loadFeature(featureName: string) {
switch (featureName) {
case 'analytics':
return import('./features/analytics');
case 'reporting':
return import('./features/reporting');
default:
throw new Error(`Unknown feature: ${featureName}`);
}
}
// Good: Conditional loading
const loadUserDashboard = async (userRole: UserRole) => {
if (userRole === UserRole.ADMIN) {
return import('./admin-dashboard');
} else if (userRole === UserRole.MODERATOR) {
return import('./moderator-dashboard');
} else {
return import('./user-dashboard');
}
};
4. Testing Best Practices
Comprehensive Testing Strategy
// Good: Unit tests
describe('UserService', () => {
let userService: UserService;
let mockRepository: jest.Mocked<UserRepository>;
beforeEach(() => {
mockRepository = createMockRepository();
userService = new UserService(mockRepository);
});
describe('createUser', () => {
it('should create user with valid data', async () => {
// Arrange
const userData: CreateUserRequest = {
name: 'John Doe',
email: '[email protected]',
role: UserRole.USER
};
const expectedUser: User = {
id: '1',
...userData,
createdAt: new Date(),
updatedAt: new Date()
};
mockRepository.save.mockResolvedValue(expectedUser);
// Act
const result = await userService.createUser(userData);
// Assert
expect(result).toEqual(expectedUser);
expect(mockRepository.save).toHaveBeenCalledWith(expect.objectContaining(userData));
});
it('should throw ValidationError for invalid email', async () => {
// Arrange
const userData: CreateUserRequest = {
name: 'John Doe',
email: 'invalid-email',
role: UserRole.USER
};
// Act & Assert
await expect(userService.createUser(userData)).rejects.toThrow(ValidationError);
});
});
});
// Good: Integration tests
describe('User API Integration', () => {
let app: Express;
let testDb: Database;
beforeAll(async () => {
testDb = await createTestDatabase();
app = createApp(testDb);
});
afterAll(async () => {
await testDb.close();
});
beforeEach(async () => {
await testDb.clear();
});
it('should create user via API', async () => {
const userData = {
name: 'John Doe',
email: '[email protected]',
role: 'user'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toMatchObject(userData);
expect(response.body.id).toBeDefined();
});
});
5. Documentation Best Practices
Comprehensive Documentation
/**
* User service for managing user operations
*
* @example
* ```typescript
* const userService = new UserService(userRepository);
* const user = await userService.createUser({
* name: 'John Doe',
* email: '[email protected]',
* role: UserRole.USER
* });
* ```
*/
export class UserService {
/**
* Creates a new user in the system
*
* @param userData - The user data to create
* @returns Promise that resolves to the created user
* @throws {ValidationError} When user data is invalid
* @throws {DuplicateEmailError} When email already exists
*
* @example
* ```typescript
* try {
* const user = await userService.createUser({
* name: 'John Doe',
* email: '[email protected]',
* role: UserRole.USER
* });
* console.log('User created:', user.id);
* } catch (error) {
* if (error instanceof ValidationError) {
* console.error('Validation failed:', error.message);
* }
* }
* ```
*/
async createUser(userData: CreateUserRequest): Promise<User> {
// Implementation
}
/**
* Finds a user by their ID
*
* @param id - The user ID to search for
* @returns Promise that resolves to the user or null if not found
*
* @example
* ```typescript
* const user = await userService.findById('123');
* if (user) {
* console.log('Found user:', user.name);
* } else {
* console.log('User not found');
* }
* ```
*/
async findById(id: string): Promise<User | null> {
// Implementation
}
}
/**
* User entity representing a user in the system
*
* @interface User
*/
export interface User {
/** Unique identifier for the user */
readonly id: string;
/** User's display name */
name: string;
/** User's email address (must be unique) */
email: string;
/** User's role in the system */
role: UserRole;
/** When the user was created */
readonly createdAt: Date;
/** When the user was last updated */
updatedAt: Date;
}
6. Design Patterns
Repository Pattern
// Good: Repository pattern for data access
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<User>;
update(id: string, updates: Partial<User>): Promise<User>;
delete(id: string): Promise<void>;
}
class DatabaseUserRepository implements UserRepository {
constructor(private db: Database) {}
async findById(id: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE id = ?', [id]);
return result.rows[0] || null;
}
async findByEmail(email: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE email = ?', [email]);
return result.rows[0] || null;
}
async save(user: User): Promise<User> {
await this.db.query(
'INSERT INTO users (id, name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[user.id, user.name, user.email, user.role, user.createdAt, user.updatedAt]
);
return user;
}
async update(id: string, updates: Partial<User>): Promise<User> {
const setClause = Object.keys(updates)
.filter(key => key !== 'id' && key !== 'createdAt')
.map(key => `${key} = ?`)
.join(', ');
const values = Object.values(updates).filter(value => value !== undefined);
values.push(id);
await this.db.query(
`UPDATE users SET ${setClause}, updated_at = ? WHERE id = ?`,
[...values, new Date()]
);
const updatedUser = await this.findById(id);
if (!updatedUser) {
throw new Error('User not found after update');
}
return updatedUser;
}
async delete(id: string): Promise<void> {
await this.db.query('DELETE FROM users WHERE id = ?', [id]);
}
}
Factory Pattern
// Good: Factory pattern for object creation
interface UserFactory {
createUser(userData: CreateUserRequest): User;
createAdminUser(userData: Omit<CreateUserRequest, 'role'>): User;
createModeratorUser(userData: Omit<CreateUserRequest, 'role'>): User;
}
class DefaultUserFactory implements UserFactory {
createUser(userData: CreateUserRequest): User {
return {
id: generateId(),
name: userData.name,
email: userData.email,
role: userData.role,
createdAt: new Date(),
updatedAt: new Date()
};
}
createAdminUser(userData: Omit<CreateUserRequest, 'role'>): User {
return this.createUser({
...userData,
role: UserRole.ADMIN
});
}
createModeratorUser(userData: Omit<CreateUserRequest, 'role'>): User {
return this.createUser({
...userData,
role: UserRole.MODERATOR
});
}
}
Observer Pattern
// Good: Observer pattern for event handling
interface EventListener<T> {
handle(event: T): void;
}
interface EventEmitter<T> {
subscribe(listener: EventListener<T>): void;
unsubscribe(listener: EventListener<T>): void;
emit(event: T): void;
}
class UserEventEmitter implements EventEmitter<UserEvent> {
private listeners: EventListener<UserEvent>[] = [];
subscribe(listener: EventListener<UserEvent>): void {
this.listeners.push(listener);
}
unsubscribe(listener: EventListener<UserEvent>): void {
const index = this.listeners.indexOf(listener);
if (index > -1) {
this.listeners.splice(index, 1);
}
}
emit(event: UserEvent): void {
this.listeners.forEach(listener => listener.handle(event));
}
}
// Usage
const userEventEmitter = new UserEventEmitter();
userEventEmitter.subscribe({
handle: (event) => {
if (event.type === 'user.created') {
console.log('New user created:', event.user.name);
}
}
});
Common Anti-Patterns to Avoid
1. Any Type Overuse
// ❌ Bad: Overusing any
function processData(data: any): any {
return data.map((item: any) => ({
...item,
processed: true
}));
}
// ✅ Good: Proper typing
function processData<T extends { id: string }>(data: T[]): (T & { processed: boolean })[] {
return data.map(item => ({
...item,
processed: true
}));
}
2. Type Assertions Abuse
// ❌ Bad: Excessive type assertions
const user = getUserData() as User;
const result = processData(data) as ProcessedData;
// ✅ Good: Proper type guards
function isUser(data: unknown): data is User {
return typeof data === 'object' && data !== null && 'id' in data && 'name' in data;
}
const userData = getUserData();
if (isUser(userData)) {
// userData is now typed as User
console.log(userData.name);
}
3. Ignoring Error Handling
// ❌ Bad: Ignoring errors
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return await response.json(); // No error handling
}
// ✅ Good: Proper error handling
async function fetchUser(id: string): Promise<Result<User, AppError>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return {
success: false,
error: new AppError(`HTTP error! status: ${response.status}`, response.status)
};
}
const user = await response.json();
return { success: true, data: user };
} catch (error) {
return {
success: false,
error: new AppError('Network error', 500)
};
}
}
Conclusion
TypeScript best practices and patterns are essential for creating maintainable, scalable, and robust applications. By understanding:
- What best practices and patterns are and their importance
- Why they're crucial for code quality and maintainability
- How to apply them in real-world scenarios
You can write TypeScript code that is not only type-safe but also follows industry standards and best practices. These practices ensure that your code is readable, maintainable, and scalable, making it easier for teams to collaborate and for applications to evolve over time.
Next Steps
- Practice applying these best practices in your projects
- Explore advanced design patterns and architectural approaches
- Contribute to open-source TypeScript projects
- Continue learning and staying updated with TypeScript evolution
This tutorial is part of the TypeScript Mastery series by syscook.dev