TypeScript Best Practices: Write Type-Safe, Maintainable Code
Master TypeScript with advanced patterns, type utilities, generics, strict mode, error handling, and best practices for building scalable applications. From basics to advanced type manipulation.
Introduction
TypeScript transforms JavaScript development by adding static types, catching errors at compile time rather than runtime. This guide covers best practices from basic typing to advanced patterns used in production codebases.
Essential Configuration
Strict tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src//*"],
"exclude": ["node_modules", "dist"]
}
Type Fundamentals
Prefer Interfaces for Objects
// Good: Interfaces are extendable and have better error messages
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'user' | 'guest';
createdAt: Date;
}interface AdminUser extends User {
role: 'admin';
permissions: string[];
}
// Type aliases for unions, primitives, and utilities
type UserRole = 'admin' | 'user' | 'guest';
type UserId = string;
type Nullable = T | null;
Const Assertions
// Without const assertion - type is string[]
const roles = ['admin', 'user', 'guest'];// With const assertion - type is readonly ['admin', 'user', 'guest']
const roles = ['admin', 'user', 'guest'] as const;
type Role = typeof roles[number]; // 'admin' | 'user' | 'guest'
// Object const assertion
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
} as const;
type Config = typeof config;
// { readonly apiUrl: "https://api.example.com"; readonly timeout: 5000; readonly retries: 3 }
Discriminated Unions
// Define result types with discriminant
interface Success {
success: true;
data: T;
}interface Failure {
success: false;
error: string;
code: number;
}
type Result = Success | Failure;
// Usage with type narrowing
function handleResult(result: Result): T | null {
if (result.success) {
// TypeScript knows result is Success here
return result.data;
} else {
// TypeScript knows result is Failure here
console.error(Error ${result.code}: ${result.error});
return null;
}
}
// API response types
interface ApiResponseSuccess {
status: 'success';
data: T;
meta?: {
page: number;
total: number;
};
}
interface ApiResponseError {
status: 'error';
message: string;
errors?: Record;
}
type ApiResponse = ApiResponseSuccess | ApiResponseError;
async function fetchUser(id: string): Promise> {
try {
const response = await fetch(/api/users/${id});
const data = await response.json();
return { status: 'success', data };
} catch (error) {
return {
status: 'error',
message: error instanceof Error ? error.message : 'Unknown error'
};
}
}
Advanced Type Patterns
Generic Constraints
// Constrain generic to have specific properties
interface HasId {
id: string;
}function findById(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// Constrain to object keys
function pick(obj: T, keys: K[]): Pick {
const result = {} as Pick;
for (const key of keys) {
result[key] = obj[key];
}
return result;
}
const user: User = {
id: '1',
email: 'john@example.com',
name: 'John',
role: 'user',
createdAt: new Date()
};
const subset = pick(user, ['id', 'email']);
// Type: { id: string; email: string }
Mapped Types
// Make all properties optional
type Partial = {
[P in keyof T]?: T[P];
};// Make all properties required
type Required = {
[P in keyof T]-?: T[P];
};
// Make all properties readonly
type Readonly = {
readonly [P in keyof T]: T[P];
};
// Custom mapped types
type Nullable = {
[P in keyof T]: T[P] | null;
};
type Mutable = {
-readonly [P in keyof T]: T[P];
};
// Rename keys
type Getters = {
[P in keyof T as get${Capitalize} ]: () => T[P];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters;
// { getName: () => string; getAge: () => number }
Conditional Types
// Basic conditional type
type IsString = T extends string ? true : false;// Extract array element type
type ArrayElement = T extends (infer E)[] ? E : never;
type StringArray = string[];
type Element = ArrayElement; // string
// Extract function return type
type ReturnType = T extends (...args: any[]) => infer R ? R : never;
// Extract Promise value
type Awaited = T extends Promise ? Awaited : T;
type NestedPromise = Promise>;
type Resolved = Awaited; // string
// Exclude and Extract
type Exclude = T extends U ? never : T;
type Extract = T extends U ? T : never;
type Numbers = 1 | 2 | 3 | 4 | 5;
type SmallNumbers = Extract; // 1 | 2 | 3
type BigNumbers = Exclude; // 4 | 5
Template Literal Types
// HTTP methods
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';// Route patterns
type ApiRoute = /api/${string};
type UserRoute = /api/users/${string};
// Event names
type EventName = on${Capitalize} ;
type ClickEvent = EventName<'click'>; // 'onClick'
type SubmitEvent = EventName<'submit'>; // 'onSubmit'
// Build object type from event names
type EventHandlers = {
[E in Events as EventName]?: () => void;
};
type MouseEvents = 'click' | 'mouseenter' | 'mouseleave';
type MouseHandlers = EventHandlers;
// { onClick?: () => void; onMouseenter?: () => void; onMouseleave?: () => void }
// CSS property types
type CSSValue = ${number}${'px' | 'rem' | 'em' | '%'};
function setWidth(element: HTMLElement, width: CSSValue) {
element.style.width = width;
}
setWidth(element, '100px'); // OK
setWidth(element, '2rem'); // OK
// setWidth(element, '100'); // Error: not a valid CSSValue
Error Handling
Type-Safe Error Classes
// Base error with type discrimination
abstract class AppError extends Error {
abstract readonly code: string;
abstract readonly statusCode: number; constructor(message: string) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
toJSON() {
return {
name: this.name,
code: this.code,
message: this.message,
statusCode: this.statusCode
};
}
}
class NotFoundError extends AppError {
readonly code = 'NOT_FOUND';
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(${resource} with id ${id} not found);
}
}
class ValidationError extends AppError {
readonly code = 'VALIDATION_ERROR';
readonly statusCode = 400;
readonly errors: Record;
constructor(errors: Record) {
super('Validation failed');
this.errors = errors;
}
toJSON() {
return {
...super.toJSON(),
errors: this.errors
};
}
}
class UnauthorizedError extends AppError {
readonly code = 'UNAUTHORIZED';
readonly statusCode = 401;
constructor(message = 'Authentication required') {
super(message);
}
}
// Type guard for error handling
function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}
// Usage
function handleError(error: unknown): void {
if (isAppError(error)) {
// Type-safe access to error properties
console.log([${error.code}] ${error.message});
if (error instanceof ValidationError) {
// TypeScript knows about errors property
console.log('Validation errors:', error.errors);
}
} else if (error instanceof Error) {
console.log('Unexpected error:', error.message);
} else {
console.log('Unknown error:', error);
}
}
Result Pattern (No Exceptions)
// Result type for explicit error handling
type Result =
| { ok: true; value: T }
| { ok: false; error: E };// Helper functions
function ok(value: T): Result {
return { ok: true, value };
}
function err(error: E): Result {
return { ok: false, error };
}
// Usage in services
interface CreateUserDTO {
email: string;
password: string;
name: string;
}
type CreateUserError =
| { type: 'EMAIL_EXISTS'; email: string }
| { type: 'WEAK_PASSWORD'; requirements: string[] }
| { type: 'INVALID_EMAIL'; email: string };
async function createUser(
dto: CreateUserDTO
): Promise> {
// Validate email format
if (!isValidEmail(dto.email)) {
return err({ type: 'INVALID_EMAIL', email: dto.email });
}
// Check password strength
const passwordIssues = validatePassword(dto.password);
if (passwordIssues.length > 0) {
return err({ type: 'WEAK_PASSWORD', requirements: passwordIssues });
}
// Check if email exists
const existingUser = await userRepository.findByEmail(dto.email);
if (existingUser) {
return err({ type: 'EMAIL_EXISTS', email: dto.email });
}
// Create user
const user = await userRepository.create(dto);
return ok(user);
}
// Caller handles all cases explicitly
async function handleCreateUser(dto: CreateUserDTO) {
const result = await createUser(dto);
if (!result.ok) {
switch (result.error.type) {
case 'EMAIL_EXISTS':
return { status: 400, message: Email ${result.error.email} already registered };
case 'WEAK_PASSWORD':
return { status: 400, message: 'Password requirements not met', requirements: result.error.requirements };
case 'INVALID_EMAIL':
return { status: 400, message: 'Invalid email format' };
}
}
return { status: 201, user: result.value };
}
API Type Safety
Request/Response Types
// Define API contract
interface ApiEndpoints {
'/users': {
GET: {
query: { page?: number; limit?: number };
response: { users: User[]; total: number };
};
POST: {
body: CreateUserDTO;
response: User;
};
};
'/users/:id': {
GET: {
params: { id: string };
response: User;
};
PUT: {
params: { id: string };
body: UpdateUserDTO;
response: User;
};
DELETE: {
params: { id: string };
response: void;
};
};
}// Type-safe API client
type ExtractParams = T extends ${infer _Start}:${infer Param}/${infer Rest}
? { [K in Param | keyof ExtractParams]: string }
: T extends ${infer _Start}:${infer Param}
? { [K in Param]: string }
: {};
class TypedApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get
(
path: P,
options?: {
params?: ExtractParams
;
query?: ApiEndpoints[P] extends { GET: { query: infer Q } } ? Q : never;
}
): Promise {
let url = this.baseUrl + path;
// Replace path params
if (options?.params) {
for (const [key, value] of Object.entries(options.params)) {
url = url.replace(:${key}, value);
}
}
// Add query params
if (options?.query) {
const queryString = new URLSearchParams(
options.query as Record
).toString();
url += ?${queryString};
}
const response = await fetch(url);
return response.json();
}
async post
(
path: P,
body: ApiEndpoints[P] extends { POST: { body: infer B } } ? B : never,
options?: { params?: ExtractParams
}
): Promise {
let url = this.baseUrl + path;
if (options?.params) {
for (const [key, value] of Object.entries(options.params)) {
url = url.replace(:${key}, value);
}
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return response.json();
}
}
// Usage - fully type-safe
const api = new TypedApiClient('https://api.example.com');
// TypeScript knows the response type
const users = await api.get('/users', { query: { page: 1, limit: 10 } });
// Type: { users: User[]; total: number }
const user = await api.get('/users/:id', { params: { id: '123' } });
// Type: User
Zod for Runtime Validation
import { z } from 'zod';// Define schemas
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(2).max(100),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.coerce.date()
});
// Infer TypeScript type from schema
type User = z.infer;
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
type CreateUserDTO = z.infer;
// Validation with type narrowing
function validateUser(data: unknown): User {
return UserSchema.parse(data);
}
// Safe validation (doesn't throw)
function safeValidateUser(data: unknown): Result {
const result = UserSchema.safeParse(data);
if (result.success) {
return ok(result.data);
}
return err(result.error);
}
// Express middleware
import { Request, Response, NextFunction } from 'express';
function validateBody(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.errors.map(e => ({
path: e.path.join('.'),
message: e.message
}))
});
}
// Attach validated data to request
req.body = result.data;
next();
};
}
// Usage
app.post('/users', validateBody(CreateUserSchema), async (req, res) => {
// req.body is now typed as CreateUserDTO
const user = await createUser(req.body);
res.json(user);
});
Utility Patterns
Builder Pattern
class QueryBuilder {
private filters: Array<(item: T) => boolean> = [];
private sortKey?: keyof T;
private sortOrder: 'asc' | 'desc' = 'asc';
private limitCount?: number;
private offsetCount = 0; where(key: K, value: T[K]): this {
this.filters.push(item => item[key] === value);
return this;
}
whereIn(key: K, values: T[K][]): this {
this.filters.push(item => values.includes(item[key]));
return this;
}
orderBy(key: keyof T, order: 'asc' | 'desc' = 'asc'): this {
this.sortKey = key;
this.sortOrder = order;
return this;
}
limit(count: number): this {
this.limitCount = count;
return this;
}
offset(count: number): this {
this.offsetCount = count;
return this;
}
execute(items: T[]): T[] {
let result = [...items];
// Apply filters
for (const filter of this.filters) {
result = result.filter(filter);
}
// Apply sorting
if (this.sortKey) {
const key = this.sortKey;
const order = this.sortOrder;
result.sort((a, b) => {
if (a[key] < b[key]) return order === 'asc' ? -1 : 1;
if (a[key] > b[key]) return order === 'asc' ? 1 : -1;
return 0;
});
}
// Apply pagination
result = result.slice(this.offsetCount);
if (this.limitCount) {
result = result.slice(0, this.limitCount);
}
return result;
}
}
// Usage
const query = new QueryBuilder()
.where('role', 'admin')
.orderBy('createdAt', 'desc')
.limit(10)
.offset(0);
const admins = query.execute(users);
Factory Pattern
// Payment processor factory
interface PaymentProcessor {
processPayment(amount: number): Promise;
refund(transactionId: string): Promise;
}interface PaymentConfig {
type: 'stripe' | 'paypal' | 'razorpay';
apiKey: string;
environment: 'sandbox' | 'production';
}
class StripeProcessor implements PaymentProcessor {
constructor(private config: Omit) {}
async processPayment(amount: number): Promise {
// Stripe implementation
return { success: true, transactionId: 'str_123' };
}
async refund(transactionId: string): Promise {
return { success: true };
}
}
class PayPalProcessor implements PaymentProcessor {
constructor(private config: Omit) {}
async processPayment(amount: number): Promise {
// PayPal implementation
return { success: true, transactionId: 'pp_123' };
}
async refund(transactionId: string): Promise {
return { success: true };
}
}
class PaymentProcessorFactory {
static create(config: PaymentConfig): PaymentProcessor {
switch (config.type) {
case 'stripe':
return new StripeProcessor(config);
case 'paypal':
return new PayPalProcessor(config);
case 'razorpay':
return new RazorpayProcessor(config);
default:
const _exhaustive: never = config.type;
throw new Error(Unknown payment type: ${_exhaustive});
}
}
}
// Usage
const processor = PaymentProcessorFactory.create({
type: 'stripe',
apiKey: process.env.STRIPE_KEY!,
environment: 'production'
});
const result = await processor.processPayment(9999);
Common Anti-Patterns to Avoid
1. Avoid any
// Bad
function processData(data: any) {
return data.value; // No type safety
}// Good
function processData(data: T): T['value'] {
return data.value;
}
// Or use unknown with type guards
function processUnknown(data: unknown): string {
if (typeof data === 'object' && data !== null && 'value' in data) {
return String((data as { value: unknown }).value);
}
throw new Error('Invalid data');
}
2. Avoid Type Assertions
// Bad - bypasses type checking
const user = response.data as User;// Good - validate at runtime
const user = UserSchema.parse(response.data);
// Good - use type guards
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'email' in data
);
}
if (isUser(response.data)) {
// response.data is User here
}
3. Avoid Optional Chaining Abuse
// Bad - hides potential issues
const email = user?.profile?.email?.toLowerCase();// Good - be explicit about what can be undefined
interface User {
profile: {
email: string; // Required
};
}
// Or handle the undefined case explicitly
function getUserEmail(user: User | undefined): string | undefined {
if (!user) return undefined;
return user.profile.email.toLowerCase();
}
Best Practices Summary
1. Enable strict mode - All strict options in tsconfig.json 2. Prefer interfaces - For object types that may be extended 3. Use const assertions - For literal types and immutability 4. Discriminated unions - For handling multiple related types 5. Avoid any - Use unknown with type guards instead 6. Runtime validation - Use Zod for external data 7. Result types - For explicit error handling 8. Generic constraints - Make functions type-safe and reusable
---
Building type-safe applications? Connect on LinkedIn to discuss TypeScript patterns and best practices.
Related Articles
- REST API Design Best Practices - Type-safe API design
- Testing Strategies: Unit, Integration, E2E - Test TypeScript code
- Authentication & Authorization Guide - Type-safe auth
- Python Asyncio Complete Guide - Compare with async TypeScript