TypeScript Best Practices for Modern Applications
TypeScript Best Practices for Modern Applications
TypeScript has become the de facto standard for building large-scale JavaScript applications. Here are the essential practices that will help you write better, more maintainable TypeScript code.
1. Strict Type Configuration
Always start with strict TypeScript configuration:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noImplicitThis": true
}
}
This catches potential issues early and enforces better coding practices.
2. Use Type Guards Effectively
Type guards help TypeScript understand your runtime checks:
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function processValue(value: unknown) {
if (isString(value)) {
// TypeScript knows value is string here
console.log(value.toUpperCase());
}
}
3. Leverage Union Types and Discriminated Unions
Union types provide type safety for different possibilities:
type Status = 'loading' | 'success' | 'error';
interface LoadingState {
status: 'loading';
}
interface SuccessState {
status: 'success';
data: any;
}
interface ErrorState {
status: 'error';
error: string;
}
type AppState = LoadingState | SuccessState | ErrorState;
4. Use Utility Types
TypeScript provides powerful utility types:
interface User {
id: number;
name: string;
email: string;
password: string;
}
// Create a type without password
type PublicUser = Omit<User, 'password'>;
// Create a type with only id and name
type UserSummary = Pick<User, 'id' | 'name'>;
// Make all properties optional
type PartialUser = Partial<User>;
// Make all properties required
type RequiredUser = Required<User>;
5. Generic Types for Reusability
Generics make your code reusable and type-safe:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface PaginatedResponse<T> extends ApiResponse<T[]> {
pagination: {
page: number;
limit: number;
total: number;
};
}
// Usage
type UserResponse = ApiResponse<User>;
type UsersResponse = PaginatedResponse<User>;
6. Proper Error Handling
Create typed error classes:
class AppError extends Error {
constructor(
public message: string,
public code: string,
public statusCode: number = 500
) {
super(message);
this.name = 'AppError';
}
}
class ValidationError extends AppError {
constructor(message: string, public field: string) {
super(message, 'VALIDATION_ERROR', 400);
}
}
// Usage with type guards
function handleError(error: unknown) {
if (error instanceof ValidationError) {
console.log(`Validation error on field: ${error.field}`);
} else if (error instanceof AppError) {
console.log(`App error: ${error.message} (${error.code})`);
} else {
console.log('Unknown error:', error);
}
}
7. Environment Configuration
Type your environment variables:
interface EnvironmentConfig {
NODE_ENV: 'development' | 'production' | 'test';
API_URL: string;
DATABASE_URL: string;
JWT_SECRET: string;
PORT: number;
}
function getEnvironmentConfig(): EnvironmentConfig {
return {
NODE_ENV: process.env.NODE_ENV as EnvironmentConfig['NODE_ENV'],
API_URL: process.env.API_URL || 'http://localhost:3000',
DATABASE_URL: process.env.DATABASE_URL || '',
JWT_SECRET: process.env.JWT_SECRET || '',
PORT: parseInt(process.env.PORT || '3000', 10),
};
}
8. Use Const Assertions
Const assertions provide more specific types:
// Without const assertion
const colors = ['red', 'green', 'blue']; // string[]
// With const assertion
const colors = ['red', 'green', 'blue'] as const; // readonly ['red', 'green', 'blue']
// Object const assertion
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
} as const;
9. Branded Types for Type Safety
Use branded types to prevent mixing 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 createProductId(id: string): ProductId {
return id as ProductId;
}
// This prevents accidentally using wrong ID types
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
10. Performance Considerations
Use Type-Only Imports
import type { User } from './types';
import { validateUser } from './validation';
Prefer Interfaces Over Types for Objects
// Preferred for objects
interface User {
id: number;
name: string;
}
// Use type for unions, primitives, computed types
type Status = 'active' | 'inactive';
type UserWithStatus = User & { status: Status };
Testing with TypeScript
Write type-safe tests:
import { describe, it, expect } from 'vitest';
import type { User } from '../types';
describe('User validation', () => {
it('should validate user correctly', () => {
const mockUser: User = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};
expect(validateUser(mockUser)).toBe(true);
});
});
Key Takeaways
- Start strict - Enable all strict compiler options
- Type everything - Avoid
anytype whenever possible - Use utility types - Leverage TypeScript’s built-in utility types
- Generic patterns - Make your code reusable with generics
- Error handling - Create typed error classes and use type guards
- Performance - Use type-only imports and prefer interfaces
TypeScript is more than just adding types to JavaScript - it’s about creating more reliable, maintainable applications through better design patterns and compile-time safety.
These practices have been tested in production applications serving millions of users. Start implementing them gradually in your projects for immediate benefits.