TypeScript Best Practices for Modern Web Development
Table of Contents
TypeScript Best Practices for Modern Web Development
TypeScript has revolutionized how we write JavaScript by adding static type checking. After years of working with TypeScript in various projects, I’ve compiled some essential best practices that will help you write better, more maintainable code.
1. Leverage Type Inference
TypeScript’s type inference is powerful. Don’t over-annotate when the compiler can figure it out:
// ❌ Over-annotatedconst message: string = 'Hello, world!';const count: number = 42;
// ✅ Let TypeScript inferconst message = 'Hello, world!';const count = 42;
However, be explicit when it adds clarity or when inference might be ambiguous:
// ✅ Good places for explicit typesconst users: User[] = [];const config: AppConfig = loadConfig();
2. Use Strict Mode
Always enable strict mode in your tsconfig.json
. It catches more potential errors and enforces better coding practices:
{ "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true }}
3. Prefer Interfaces for Object Shapes
Use interfaces for defining object shapes, especially when you might need to extend them:
interface User { id: string; name: string; email: string;}
interface AdminUser extends User { permissions: string[];}
Use type aliases for unions, primitives, and computed types:
type Status = 'loading' | 'success' | 'error';type EventHandler<T> = (event: T) => void;
4. Make Impossible States Impossible
Design your types to prevent invalid states:
// ❌ Can have inconsistent stateinterface ApiState { loading: boolean; data: any; error: string | null;}
// ✅ Impossible to have inconsistent statetype ApiState = | { status: 'loading' } | { status: 'success'; data: any } | { status: 'error'; error: string };
5. Use Utility Types
TypeScript provides powerful utility types. Learn and use them:
interface User { id: string; name: string; email: string; password: string;}
// Create types from existing onestype PublicUser = Omit<User, 'password'>;type UserUpdate = Partial<Pick<User, 'name' | 'email'>>;type UserKeys = keyof User; // 'id' | 'name' | 'email' | 'password'
6. Generic Functions and Classes
Use generics to create reusable, type-safe utilities:
function identity<T>(arg: T): T { return arg;}
class Repository<T> { private items: T[] = [];
add(item: T): void { this.items.push(item); }
findById<K extends keyof T>(key: K, value: T[K]): T | undefined { return this.items.find((item) => item[key] === value); }}
7. Proper Error Handling
Define error types and handle them appropriately:
class ValidationError extends Error { constructor(public field: string, message: string) { super(message); this.name = 'ValidationError'; }}
function validateEmail(email: string): string { if (!email.includes('@')) { throw new ValidationError('email', 'Invalid email format'); } return email;}
8. Use Branded Types for Type Safety
Create branded types to prevent mixing up similar primitive types:
type UserId = string & { readonly brand: unique symbol };type ProductId = string & { readonly brand: unique symbol };
function createUserId(id: string): UserId { return id as UserId;}
function getUser(id: UserId): User { // Implementation}
const userId = createUserId('user-123');const productId = 'product-456' as ProductId;
getUser(userId); // ✅ WorksgetUser(productId); // ❌ TypeScript error
9. Organize Types
Keep your types organized and discoverable:
export interface User { id: UserId; name: string; email: string;}
export type UserRole = 'admin' | 'user' | 'guest';
// types/api.tsexport interface ApiResponse<T> { data: T; status: number; message?: string;}
// types/index.tsexport * from './user';export * from './api';
10. Testing with TypeScript
Write type-safe tests:
import { describe, it, expect } from 'vitest';import type { User } from '../types';
describe('UserService', () => { it('should create a user', () => { const userData: Omit<User, 'id'> = { name: 'John Doe', email: 'john@example.com', };
const user = userService.create(userData);
expect(user).toMatchObject({ ...userData, id: expect.any(String), }); });});
Conclusion
These practices have served me well across numerous TypeScript projects. The key is to let TypeScript’s type system guide you toward better code design while avoiding over-engineering.
Remember: TypeScript is about developer experience and catching errors early. Use it to make your code more reliable and your development process smoother.
Have your own TypeScript tips? I’d love to hear about them! Connect with me on Twitter to continue the conversation.