TypeScript Best Practices: Writing Maintainable Enterprise Code

TypeScript Best Practices: Writing Maintainable Enterprise Code

BySanjay Goraniya
2 min read
Share:

TypeScript Best Practices: Writing Maintainable Enterprise Code

TypeScript has become the standard for large-scale JavaScript applications, and for good reason. But writing TypeScript that's truly maintainable requires more than just adding type annotations. After working with TypeScript in enterprise codebases for years, I've learned what works and what doesn't. Let me share the practices that have made the biggest difference.

Type Safety Without Over-Engineering

Use Strict Mode

Always enable strict mode in your tsconfig.json:

Code
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Yes, it's more work initially, but it catches bugs at compile time that would surface in production.

Prefer Interfaces for Object Shapes

Use interfaces for object shapes, types for unions and intersections:

Code
// Good: Interface for object shape
interface User {
  id: string;
  name: string;
  email: string;
}

// Good: Type for union
type Status = 'pending' | 'approved' | 'rejected';

// Good: Type for complex types
type UserWithStatus = User & { status: Status };

Effective Type Definitions

Avoid any, Use unknown

any disables type checking. Use unknown when you truly don't know the type:

Code
// Bad
function processData(data: any) {
  return data.someProperty; // No type safety
}

// Good
function processData(data: unknown) {
  if (typeof data === 'object' && data !== null && 'someProperty' in data) {
    return (data as { someProperty: string }).someProperty;
  }
  throw new Error('Invalid data');
}

Use Discriminated Unions

For better type narrowing:

Code
type Result<T> =
  | { success: true; data: T }
  | { success: false; error: string };

function handleResult<T>(result: Result<T>) {
  if (result.success) {
    // TypeScript knows result.data exists here
    console.log(result.data);
  } else {
    // TypeScript knows result.error exists here
    console.error(result.error);
  }
}

Generics: When and How

Use Generics for Reusability

Code
// Good: Generic function
function getById<T extends { id: string }>(
  items: T[],
  id: string
): T | undefined {
  return items.find(item => item.id === id);
}

// Usage
const user = getById(users, '123'); // Type: User | undefined
const product = getById(products, '456'); // Type: Product | undefined

Constrain Generics Appropriately

Code
// Bad: Too permissive
function process<T>(item: T): T {
  // Can't do much with T
}

// Good: Constrained
function process<T extends { id: string; status: string }>(
  item: T
): T {
  // Can safely access item.id and item.status
  return { ...item, status: 'processed' };
}

Utility Types: Your Best Friends

Common Utility Types

Code
// Partial: Make all properties optional
type PartialUser = Partial<User>;

// Pick: Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;

// Omit: Exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>;

// Record: Dictionary type
type UserMap = Record<string, User>;

// Readonly: Make properties readonly
type ImmutableUser = Readonly<User>;

Create Your Own Utility Types

Code
// Make specific properties required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;

type UserWithRequiredEmail = RequireFields<User, 'email'>;

// Make specific properties optional
type OptionalFields<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

type UserWithOptionalEmail = OptionalFields<User, 'email'>;

Function Signatures

Explicit Return Types

Always specify return types for public functions:

Code
// Good
function calculateTotal(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price, 0);
}

// Bad: Return type inferred (can change unexpectedly)
function calculateTotal(items: Item[]) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

Use Function Overloads Sparingly

Code
// Good: Clear overloads
function format(value: string): string;
function format(value: number): string;
function format(value: string | number): string {
  return String(value);
}

// Bad: Too many overloads (use union types instead)
function process(value: string): string;
function process(value: number): number;
function process(value: boolean): boolean;
// ... 10 more overloads

Error Handling

Use Result Types Instead of Exceptions

Code
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const user = await api.getUser(id);
    return { ok: true, value: user };
  } catch (error) {
    return { ok: false, error: error as Error };
  }
}

// Usage
const result = await fetchUser('123');
if (result.ok) {
  console.log(result.value); // Type-safe access
} else {
  console.error(result.error);
}

Class Design

Prefer Composition Over Inheritance

Code
// Bad: Deep inheritance
class Animal {}
class Mammal extends Animal {}
class Dog extends Mammal {}

// Good: Composition
interface Flyable {
  fly(): void;
}

interface Swimmable {
  swim(): void;
}

class Duck {
  private flyable: Flyable;
  private swimmable: Swimmable;
  
  constructor(flyable: Flyable, swimmable: Swimmable) {
    this.flyable = flyable;
    this.swimmable = swimmable;
  }
}

Use Private Fields

Code
class UserService {
  private apiKey: string; // Truly private
  #internalCache: Map<string, any>; // Also private
  
  constructor(apiKey: string) {
    this.apiKey = apiKey;
    this.#internalCache = new Map();
  }
}

Async Patterns

Proper Async/Await Usage

Code
// Good: Sequential when order matters
async function processOrder(order: Order) {
  const user = await getUser(order.userId);
  const inventory = await checkInventory(order.items);
  const payment = await processPayment(order);
  return { user, inventory, payment };
}

// Good: Parallel when order doesn't matter
async function processOrder(order: Order) {
  const [user, inventory, payment] = await Promise.all([
    getUser(order.userId),
    checkInventory(order.items),
    processPayment(order)
  ]);
  return { user, inventory, payment };
}

Type-Safe Promises

Code
// Good: Explicit Promise types
async function fetchData(): Promise<Data> {
  const response = await fetch('/api/data');
  return response.json();
}

// Good: Promise utility type
type AsyncResult<T> = Promise<Result<T>>;

async function safeFetch<T>(url: string): AsyncResult<T> {
  try {
    const response = await fetch(url);
    const data = await response.json();
    return { ok: true, value: data };
  } catch (error) {
    return { ok: false, error: error as Error };
  }
}

Testing with TypeScript

Type-Safe Test Utilities

Code
// Test helper with proper types
function createMockUser(overrides?: Partial<User>): User {
  return {
    id: '1',
    name: 'Test User',
    email: 'test@example.com',
    ...overrides
  };
}

// Usage
const user = createMockUser({ name: 'Custom Name' });

Type Guards in Tests

Code
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    'email' in obj
  );
}

test('returns valid user', () => {
  const result = getUser('123');
  expect(isUser(result)).toBe(true);
  if (isUser(result)) {
    expect(result.name).toBeDefined(); // Type-safe
  }
});

Common Anti-Patterns to Avoid

1. Type Assertions as a Crutch

Code
// Bad: Using 'as' to silence errors
const user = data as User; // Dangerous!

// Good: Proper type checking
if (isUser(data)) {
  const user = data; // Type-safe
}

2. Optional Chaining Everywhere

Code
// Bad: Hiding potential issues
const name = user?.profile?.details?.name ?? 'Unknown';

// Good: Handle the case properly
if (user?.profile?.details?.name) {
  const name = user.profile.details.name;
  // Use name
} else {
  // Handle missing name
}

3. Ignoring Compiler Warnings

Fix warnings, don't suppress them. They're usually pointing to real issues.

Project Organization

Barrel Exports

Code
// types/index.ts
export type { User, Product, Order } from './entities';
export type { ApiResponse, PaginatedResponse } from './api';
export type { Result, Option } from './utils';

Separate Types from Implementation

Code
// types/user.ts
export interface User {
  id: string;
  name: string;
}

// services/user.ts
import type { User } from '../types/user';

export class UserService {
  async getUser(id: string): Promise<User> {
    // Implementation
  }
}

Conclusion

TypeScript is powerful, but with great power comes great responsibility. The key to maintainable TypeScript code is:

  1. Use strict mode - Catch errors early
  2. Prefer types over assertions - Let TypeScript do its job
  3. Create reusable types - DRY applies to types too
  4. Document complex types - Future you will thank you
  5. Refactor incrementally - You don't need to type everything at once

Remember: TypeScript is a tool to help you write better code, not a goal in itself. Focus on type safety that provides real value, not type safety for its own sake.

What TypeScript patterns have you found most useful in your projects? I'd love to hear about your experiences.

Share:

Related Posts