TypeScript Best Practices: Building Scalable Applications

Learn essential TypeScript best practices for building maintainable, scalable applications. From type design to advanced patterns and performance optimization.

Nosgnoh
March 10, 2024
8 minute read
TypeScript Best Practices: Building Scalable Applications

TypeScript Best Practices: Building Scalable Applications

TypeScript has become the de facto standard for large-scale JavaScript applications. This guide covers essential best practices for writing maintainable, scalable TypeScript code.

Type Design Principles

1. Design Types First

// ✅ Good: Design comprehensive types first
interface User {
  readonly id: string;
  email: string;
  profile: UserProfile;
  preferences: UserPreferences;
  createdAt: Date;
  updatedAt: Date;
}

interface UserProfile {
  firstName: string;
  lastName: string;
  avatar?: string;
  bio?: string;
}

interface UserPreferences {
  theme: 'light' | 'dark' | 'auto';
  notifications: NotificationSettings;
  privacy: PrivacySettings;
}

// ✅ Good: Use branded types for IDs
type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };

const createUserId = (id: string): UserId => id as UserId;
const createPostId = (id: string): PostId => id as PostId;

2. Leverage Union Types

// ✅ Good: Discriminated unions for state management
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function handleUserState(state: AsyncState<User>) {
  switch (state.status) {
    case 'idle':
      return <div>Click to load user</div>;
    case 'loading':
      return <div>Loading...</div>;
    case 'success':
      return <UserProfile user={state.data} />;
    case 'error':
      return <div>Error: {state.error}</div>;
  }
}

// ✅ Good: Union types for flexible APIs
type ApiResponse<T> = 
  | { success: true; data: T }
  | { success: false; error: ApiError };

interface ApiError {
  code: string;
  message: string;
  details?: Record<string, unknown>;
}

Advanced Type Patterns

1. Utility Types and Mapped Types

// ✅ Good: Custom utility types
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type RequiredKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;

// Usage
type CreateUserInput = Optional<User, 'id' | 'createdAt' | 'updatedAt'>;
type UserWithRequiredEmail = RequiredKeys<Partial<User>, 'email'>;

// ✅ Good: Conditional types
type NonNullable<T> = T extends null | undefined ? never : T;
type ArrayElement<T> = T extends (infer U)[] ? U : never;

// ✅ Good: Template literal types
type EventName<T extends string> = `on${Capitalize<T>}`;
type ComponentProps<T extends string> = {
  [K in EventName<T>]?: () => void;
};

// Usage: { onClick?: () => void; onHover?: () => void; }
type ButtonProps = ComponentProps<'click' | 'hover'>;

2. Generic Constraints

// ✅ Good: Constrained generics
interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

// ✅ Good: Keyof constraints
function updateField<T, K extends keyof T>(
  obj: T,
  field: K,
  value: T[K]
): T {
  return { ...obj, [field]: value };
}

// ✅ Good: Conditional constraints
type ApiMethod<T> = T extends string 
  ? T extends `get${infer _}` 
    ? 'GET'
    : T extends `post${infer _}`
    ? 'POST'
    : 'GET'
  : never;

Error Handling Patterns

1. Result Pattern

// ✅ Good: Result type for error handling
type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };

async function fetchUser(id: string): Promise<Result<User, ApiError>> {
  try {
    const response = await api.get(`/users/${id}`);
    return { success: true, data: response.data };
  } catch (error) {
    return { 
      success: false, 
      error: { 
        code: 'FETCH_ERROR',
        message: error.message 
      }
    };
  }
}

// Usage
const userResult = await fetchUser('123');
if (userResult.success) {
  console.log(userResult.data.email); // Type-safe access
} else {
  console.error(userResult.error.message);
}

2. Option Pattern

// ✅ Good: Option type for nullable values
abstract class Option<T> {
  abstract isSome(): this is Some<T>;
  abstract isNone(): this is None;
  abstract map<U>(fn: (value: T) => U): Option<U>;
  abstract flatMap<U>(fn: (value: T) => Option<U>): Option<U>;
  abstract getOrElse(defaultValue: T): T;
}

class Some<T> extends Option<T> {
  constructor(private value: T) {
    super();
  }

  isSome(): this is Some<T> { return true; }
  isNone(): this is None { return false; }

  map<U>(fn: (value: T) => U): Option<U> {
    return new Some(fn(this.value));
  }

  flatMap<U>(fn: (value: T) => Option<U>): Option<U> {
    return fn(this.value);
  }

  getOrElse(_: T): T {
    return this.value;
  }
}

class None extends Option<never> {
  isSome(): this is Some<never> { return false; }
  isNone(): this is None { return true; }

  map<U>(_: (value: never) => U): Option<U> {
    return new None();
  }

  flatMap<U>(_: (value: never) => Option<U>): Option<U> {
    return new None();
  }

  getOrElse<T>(defaultValue: T): T {
    return defaultValue;
  }
}

Configuration and Environment

1. Type-Safe Configuration

// ✅ Good: Strongly typed configuration
interface DatabaseConfig {
  host: string;
  port: number;
  username: string;
  password: string;
  database: string;
  ssl: boolean;
}

interface ApiConfig {
  baseUrl: string;
  timeout: number;
  retries: number;
  apiKey: string;
}

interface AppConfig {
  environment: 'development' | 'staging' | 'production';
  port: number;
  database: DatabaseConfig;
  api: ApiConfig;
  features: FeatureFlags;
}

// ✅ Good: Environment variable validation
function parseConfig(): AppConfig {
  const requiredEnvVars = [
    'NODE_ENV',
    'PORT',
    'DB_HOST',
    'DB_PORT',
    'API_BASE_URL'
  ] as const;

  for (const envVar of requiredEnvVars) {
    if (!process.env[envVar]) {
      throw new Error(`Missing required environment variable: ${envVar}`);
    }
  }

  return {
    environment: process.env.NODE_ENV as AppConfig['environment'],
    port: parseInt(process.env.PORT!, 10),
    database: {
      host: process.env.DB_HOST!,
      port: parseInt(process.env.DB_PORT!, 10),
      username: process.env.DB_USERNAME!,
      password: process.env.DB_PASSWORD!,
      database: process.env.DB_NAME!,
      ssl: process.env.DB_SSL === 'true'
    },
    api: {
      baseUrl: process.env.API_BASE_URL!,
      timeout: parseInt(process.env.API_TIMEOUT || '5000', 10),
      retries: parseInt(process.env.API_RETRIES || '3', 10),
      apiKey: process.env.API_KEY!
    },
    features: parseFeatureFlags()
  };
}

API Design Patterns

1. Builder Pattern

// ✅ Good: Type-safe builder pattern
class QueryBuilder<T> {
  private conditions: string[] = [];
  private orderByClause = '';
  private limitValue?: number;

  where(condition: keyof T, operator: '=' | '!=' | '>' | '<', value: any): this {
    this.conditions.push(`${String(condition)} ${operator} '${value}'`);
    return this;
  }

  orderBy(field: keyof T, direction: 'ASC' | 'DESC' = 'ASC'): this {
    this.orderByClause = `ORDER BY ${String(field)} ${direction}`;
    return this;
  }

  limit(count: number): this {
    this.limitValue = count;
    return this;
  }

  build(): string {
    let query = 'SELECT * FROM table';
    
    if (this.conditions.length > 0) {
      query += ` WHERE ${this.conditions.join(' AND ')}`;
    }
    
    if (this.orderByClause) {
      query += ` ${this.orderByClause}`;
    }
    
    if (this.limitValue) {
      query += ` LIMIT ${this.limitValue}`;
    }
    
    return query;
  }
}

// Usage
const query = new QueryBuilder<User>()
  .where('email', '=', 'john@example.com')
  .where('age', '>', 18)
  .orderBy('createdAt', 'DESC')
  .limit(10)
  .build();

2. Dependency Injection

// ✅ Good: Interface-based dependency injection
interface Logger {
  info(message: string, meta?: Record<string, unknown>): void;
  error(message: string, error?: Error): void;
  warn(message: string, meta?: Record<string, unknown>): void;
}

interface UserRepository {
  findById(id: UserId): Promise<User | null>;
  save(user: User): Promise<User>;
  delete(id: UserId): Promise<void>;
}

interface EmailService {
  sendWelcomeEmail(user: User): Promise<void>;
  sendPasswordResetEmail(email: string, token: string): Promise<void>;
}

class UserService {
  constructor(
    private userRepository: UserRepository,
    private emailService: EmailService,
    private logger: Logger
  ) {}

  async createUser(input: CreateUserInput): Promise<Result<User, string>> {
    try {
      const user = await this.userRepository.save({
        ...input,
        id: createUserId(generateId()),
        createdAt: new Date(),
        updatedAt: new Date()
      });

      await this.emailService.sendWelcomeEmail(user);
      this.logger.info('User created successfully', { userId: user.id });

      return { success: true, data: user };
    } catch (error) {
      this.logger.error('Failed to create user', error);
      return { success: false, error: 'Failed to create user' };
    }
  }
}

Testing Patterns

1. Type-Safe Test Utilities

// ✅ Good: Type-safe test factories
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

function createUser(overrides: DeepPartial<User> = {}): User {
  return {
    id: createUserId('user-123'),
    email: 'test@example.com',
    profile: {
      firstName: 'John',
      lastName: 'Doe'
    },
    preferences: {
      theme: 'light',
      notifications: {
        email: true,
        push: false
      },
      privacy: {
        profileVisible: true
      }
    },
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides
  };
}

// ✅ Good: Mock type generation
type MockedFunction<T extends (...args: any[]) => any> = jest.MockedFunction<T>;

interface MockedUserRepository {
  findById: MockedFunction<UserRepository['findById']>;
  save: MockedFunction<UserRepository['save']>;
  delete: MockedFunction<UserRepository['delete']>;
}

function createMockUserRepository(): MockedUserRepository {
  return {
    findById: jest.fn(),
    save: jest.fn(),
    delete: jest.fn()
  };
}

Performance Optimization

1. Type-Only Imports

// ✅ Good: Use type-only imports when possible
import type { User, UserRepository } from './types';
import type { ComponentProps } from 'react';

// ✅ Good: Separate runtime and type imports
import { logger } from './logger';
import type { Logger } from './logger';

2. Avoid Expensive Type Computations

// ❌ Bad: Expensive recursive type
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// ✅ Good: Use simpler alternatives when possible
type ReadonlyUser = Readonly<User>;

// ✅ Good: Limit recursion depth
type DeepReadonly<T, D extends number = 3> = D extends 0
  ? T
  : {
      readonly [P in keyof T]: T[P] extends object
        ? DeepReadonly<T[P], Prev<D>>
        : T[P];
    };

Best Practices Checklist

  • ✅ Design types before implementation
  • ✅ Use discriminated unions for state management
  • ✅ Leverage utility types and mapped types
  • ✅ Implement proper error handling patterns
  • ✅ Create type-safe configuration
  • ✅ Use interface-based dependency injection
  • ✅ Write type-safe test utilities
  • ✅ Optimize with type-only imports
  • ✅ Keep type computations simple
  • ✅ Use strict TypeScript configuration

Conclusion

TypeScript's type system is incredibly powerful when used correctly. Focus on designing comprehensive types first, then build your implementation around them. This approach leads to more maintainable, scalable applications with fewer runtime errors.

Remember: Types are documentation that never goes out of date. Invest time in good type design, and it will pay dividends throughout your application's lifecycle.


Ready for more advanced TypeScript patterns? Check out my post on Advanced TypeScript Patterns for Enterprise Applications for even more sophisticated techniques.

Tags

#typescript#best-practices#type-safety#architecture#scalability
All Posts
Share this post with your network