TypeScript Best Practices for Modern JavaScript Development
TypeScript has become an essential tool for modern JavaScript development, providing static typing that helps catch errors early and improve code quality. Let's explore the best practices that will help you write better TypeScript code.
1. Strict Type Checking
Always enable strict type checking in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Benefits of Strict Mode
- Better type safety: Catches potential runtime errors at compile time
- Improved IntelliSense: Better autocompletion and error detection
- Self-documenting code: Types serve as documentation
2. Interface vs Type Aliases
When to Use Interfaces
Use interfaces for object shapes and class implementations:
// Interface for object shapes
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
// Interface with method signatures
interface UserRepository {
findById(id: number): Promise<User | null>;
save(user: Omit<User, 'id' | 'createdAt'>): Promise<User>;
delete(id: number): Promise<void>;
}
// Class implementing interface
class DatabaseUserRepository implements UserRepository {
async findById(id: number): Promise<User | null> {
// Implementation
}
async save(user: Omit<User, 'id' | 'createdAt'>): Promise<User> {
// Implementation
}
async delete(id: number): Promise<void> {
// Implementation
}
}
When to Use Type Aliases
Use type aliases for unions, intersections, and complex types:
// Union types
type Status = 'pending' | 'approved' | 'rejected';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
// Function types
type EventHandler<T> = (event: T) => void;
type Validator<T> = (value: unknown) => value is T;
// Complex types
type ApiResponse<T> = {
data?: T;
error?: string;
status: number;
};
// Mapped types
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type UserCreateInput = Optional<User, 'id' | 'createdAt'>;
3. Generics for Reusable Code
Generic Functions
// Generic function that works with any type
function createArray<T>(length: number, value: T): T[] {
return Array(length).fill(value);
}
// Generic constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// Usage
const numbers = createArray(5, 0); // number[]
const strings = createArray(3, 'hello'); // string[]
const user = { name: 'John', age: 30 };
const name = getProperty(user, 'name'); // string
const age = getProperty(user, 'age'); // number
Generic Classes
class Repository<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find((item: any) => item.id === id);
}
findAll(): T[] {
return [...this.items];
}
}
interface Entity {
id: number;
}
class UserRepository extends Repository<User> {
findByName(name: string): User | undefined {
return this.findAll().find(user => user.name === name);
}
}
4. Utility Types
Built-in Utility Types
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Make properties optional
type PartialUser = Partial<User>;
// Pick specific properties
type UserPublicInfo = Pick<User, 'id' | 'name' | 'email'>;
// Omit specific properties
type UserCreateInput = Omit<User, 'id' | 'createdAt'>;
// Make properties required
type RequiredUser = Required<PartialUser>;
// Create a type with null values
type NullableUser = { [K in keyof User]: User[K] | null };
Custom Utility Types
// Deep partial utility type
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Return value type extraction
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Async function return type
type AsyncReturnType<T extends (...args: any[]) => Promise<any>> =
T extends (...args: any[]) => Promise<infer R> ? R : never;
// Usage
type CreateUserResponse = AsyncReturnType<typeof createUser>; // Promise<User>
5. Type Guards and Type Predicates
Custom Type Guards
interface Cat {
type: 'cat';
meow(): void;
}
interface Dog {
type: 'dog';
bark(): void;
}
type Animal = Cat | Dog;
// Type guard function
function isCat(animal: Animal): animal is Cat {
return animal.type === 'cat';
}
function makeSound(animal: Animal): void {
if (isCat(animal)) {
animal.meow(); // TypeScript knows this is a Cat
} else {
animal.bark(); // TypeScript knows this is a Dog
}
}
// Generic type guard
function hasProperty<T, K extends string>(
obj: T,
prop: K
): obj is T & Record<K, unknown> {
return prop in obj;
}
instanceof and typeof Type Guards
function processValue(value: unknown): void {
if (typeof value === 'string') {
// TypeScript knows value is string here
console.log(value.toUpperCase());
} else if (typeof value === 'number') {
// TypeScript knows value is number here
console.log(value.toFixed(2));
}
if (value instanceof Date) {
// TypeScript knows value is Date here
console.log(value.toISOString());
}
}
6. Error Handling Patterns
Discriminated Unions for Error Handling
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function safeFetch<T>(url: string): Promise<Result<T>> {
try {
const response = await fetch(url);
if (!response.ok) {
return {
success: false,
error: new Error(`HTTP error! status: ${response.status}`)
};
}
const data = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error('Unknown error')
};
}
}
// Usage
async function getUser(id: number): Promise<User | null> {
const result = await safeFetch<User>(`/api/users/${id}`);
if (result.success) {
return result.data;
} else {
console.error(result.error);
return null;
}
}
7. Configuration and Environment
Environment Variable Types
// env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production' | 'test';
DATABASE_URL: string;
API_KEY: string;
PORT?: string;
}
}
}
// Config object with type safety
interface AppConfig {
port: number;
database: {
url: string;
ssl: boolean;
};
api: {
key: string;
timeout: number;
};
}
function createConfig(): AppConfig {
const port = parseInt(process.env.PORT || '3000', 10);
const isDevelopment = process.env.NODE_ENV === 'development';
return {
port,
database: {
url: process.env.DATABASE_URL,
ssl: !isDevelopment
},
api: {
key: process.env.API_KEY,
timeout: 5000
}
};
}
export const config = createConfig();
8. API Response Types
Strongly Typed API Responses
// API response types
interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: unknown;
};
meta?: {
pagination?: {
page: number;
limit: number;
total: number;
};
};
}
// Specific response types
interface UserResponse extends ApiResponse<User> {}
interface UsersResponse extends ApiResponse<User[]> {
meta?: ApiResponse['meta'] & {
pagination: NonNullable<ApiResponse['meta']>['pagination'];
};
}
// Typed API client
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get<T>(path: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`);
return response.json() as Promise<T>;
}
async post<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return response.json() as Promise<T>;
}
}
// Usage
const api = new ApiClient('/api');
const userResponse = await api.get<UserResponse>('/users/1');
9. Testing with TypeScript
Type-Safe Test Utilities
// test-utils.ts
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
wrapper?: ({ children }: { children: ReactNode }) => ReactElement;
}
function customRender(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { wrapper: Wrapper, ...rest } = options;
if (!Wrapper) {
return render(ui, rest);
}
return render(ui, { wrapper: Wrapper, ...rest });
}
// Mock type utilities
type MockFunction<T extends (...args: any[]) => any> = {
(...args: Parameters<T>): ReturnType<T>;
mock: {
calls: Parameters<T>[];
results: ReturnType<T>[];
};
};
// Type-safe mocking
const mockFetch = jest.fn() as MockFunction<typeof fetch>;
10. Best Practices Summary
Do's
- ✅ Use strict mode in tsconfig.json
- ✅ Prefer interfaces for object shapes
- ✅ Use type aliases for unions and complex types
- ✅ Implement type guards for runtime type checking
- ✅ Use generics for reusable code
- ✅ Type your environment variables
- ✅ Create specific types for API responses
Don'ts
- ❌ Use
anyunless absolutely necessary - ❌ Disable strict mode rules
- ❌ Ignore TypeScript errors
- ❌ Use
aswithout proper type checking - ❌ Create overly complex types without documentation
Conclusion
TypeScript provides powerful tools for building type-safe applications. By following these best practices, you can write more maintainable, readable, and reliable code. Remember that TypeScript is not just about catching errors—it's about better code organization, documentation, and developer experience.
Start implementing these practices gradually in your projects, and you'll see immediate improvements in your code quality and development workflow.