TypeScript Best Practices for Modern Web Development

4 min read
TypeScript Best Practices Web Development JavaScript

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-annotated
const message: string = 'Hello, world!';
const count: number = 42;
// ✅ Let TypeScript infer
const message = 'Hello, world!';
const count = 42;

However, be explicit when it adds clarity or when inference might be ambiguous:

// ✅ Good places for explicit types
const 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 state
interface ApiState {
loading: boolean;
data: any;
error: string | null;
}
// ✅ Impossible to have inconsistent state
type 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 ones
type 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); // ✅ Works
getUser(productId); // ❌ TypeScript error

9. Organize Types

Keep your types organized and discoverable:

types/user.ts
export interface User {
id: UserId;
name: string;
email: string;
}
export type UserRole = 'admin' | 'user' | 'guest';
// types/api.ts
export interface ApiResponse<T> {
data: T;
status: number;
message?: string;
}
// types/index.ts
export * 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.