TypeScript Best Practices for Enterprise Applications
Article
Dec 10, 2024
5 min read
UllrAI

TypeScript Best Practices for Enterprise Applications

Master TypeScript with proven best practices that will make your code more maintainable, scalable, and robust.

TypeScriptBest PracticesEnterpriseCode QualityDevelopment

Building Robust Applications with TypeScript

TypeScript has revolutionized how we write JavaScript, bringing type safety and enhanced developer experience to modern web development. However, to truly harness its power, you need to follow established best practices that ensure your code is maintainable, scalable, and robust.

Essential TypeScript Configuration

Strict Mode Configuration

Always start with a strict TypeScript configuration to catch potential issues early:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true
  }
}

Path Mapping for Clean Imports

Configure path mapping to avoid relative import hell:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@/components/*": ["src/components/*"],
      "@/utils/*": ["src/utils/*"],
      "@/types/*": ["src/types/*"]
    }
  }
}

Type Definition Best Practices

1. Use Interfaces for Object Shapes

// ✅ Good: Clear interface definition
interface User {
  readonly id: string;
  name: string;
  email: string;
  createdAt: Date;
  preferences?: UserPreferences;
}

interface UserPreferences {
  theme: 'light' | 'dark';
  notifications: boolean;
  language: string;
}

2. Leverage Union Types for Flexibility

// ✅ Good: Descriptive union types
type Status = 'pending' | 'approved' | 'rejected';
type Theme = 'light' | 'dark' | 'auto';

// ✅ Good: Discriminated unions
type ApiResponse<T> = 
  | { success: true; data: T }
  | { success: false; error: string };

3. Use Generic Types for Reusability

// ✅ Good: Reusable generic interface
interface ApiClient<T> {
  get(id: string): Promise<T>;
  create(data: Omit<T, 'id'>): Promise<T>;
  update(id: string, data: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}

// Usage
const userClient: ApiClient<User> = new UserApiClient();
const postClient: ApiClient<Post> = new PostApiClient();

Advanced Type Patterns

1. Utility Types for Transformation

// ✅ Good: Using utility types
type CreateUserRequest = Omit<User, 'id' | 'createdAt'>;
type UpdateUserRequest = Partial<Pick<User, 'name' | 'email'>>;
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;

2. Conditional Types for Complex Logic

// ✅ Good: Conditional types for API responses
type ApiEndpoint<T extends string> = T extends `${infer Method} ${infer Path}`
  ? Method extends 'GET'
    ? { method: 'GET'; path: Path; body?: never }
    : { method: Method; path: Path; body: unknown }
  : never;

type GetUsers = ApiEndpoint<'GET /users'>; // { method: 'GET'; path: '/users'; body?: never }
type CreateUser = ApiEndpoint<'POST /users'>; // { method: 'POST'; path: '/users'; body: unknown }

3. Mapped Types for Consistency

// ✅ Good: Mapped types for form handling
type FormState<T> = {
  [K in keyof T]: {
    value: T[K];
    error?: string;
    touched: boolean;
  };
};

type UserFormState = FormState<CreateUserRequest>;

Error Handling Patterns

1. Result Pattern for Error Handling

type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };

class UserService {
  async getUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'NETWORK_ERROR'>> {
    try {
      const user = await this.apiClient.get(id);
      return { success: true, data: user };
    } catch (error) {
      if (error.status === 404) {
        return { success: false, error: 'NOT_FOUND' };
      }
      return { success: false, error: 'NETWORK_ERROR' };
    }
  }
}

2. Custom Error Types

abstract class AppError extends Error {
  abstract readonly code: string;
  abstract readonly statusCode: number;
}

class ValidationError extends AppError {
  readonly code = 'VALIDATION_ERROR';
  readonly statusCode = 400;
  
  constructor(public field: string, message: string) {
    super(`Validation failed for ${field}: ${message}`);
  }
}

class NotFoundError extends AppError {
  readonly code = 'NOT_FOUND';
  readonly statusCode = 404;
  
  constructor(resource: string, id: string) {
    super(`${resource} with id ${id} not found`);
  }
}

Performance Optimization

1. Type-Only Imports

// ✅ Good: Type-only imports
import type { User, UserPreferences } from './types';
import { validateUser } from './validators';

// ✅ Good: Mixed imports
import { type ApiResponse, fetchData } from './api';

2. Lazy Type Loading

// ✅ Good: Lazy loading for large types
type LazyUserDetails = () => Promise<import('./user-details').UserDetails>;

class UserManager {
  async getUserDetails(id: string): Promise<Awaited<ReturnType<LazyUserDetails>>> {
    const { UserDetails } = await import('./user-details');
    return new UserDetails(id);
  }
}

Testing with TypeScript

1. Type-Safe Test Utilities

// ✅ Good: Type-safe test helpers
function createMockUser(overrides: Partial<User> = {}): User {
  return {
    id: 'test-id',
    name: 'Test User',
    email: '[email protected]',
    createdAt: new Date(),
    ...overrides,
  };
}

// ✅ Good: Type-safe API mocking
type MockApiClient<T> = {
  [K in keyof ApiClient<T>]: jest.MockedFunction<ApiClient<T>[K]>;
};

function createMockApiClient<T>(): MockApiClient<T> {
  return {
    get: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
  };
}

Common Pitfalls to Avoid

1. Avoid any Type

// ❌ Bad: Using any
function processData(data: any): any {
  return data.someProperty;
}

// ✅ Good: Use generics or unknown
function processData<T>(data: T): T extends { someProperty: infer P } ? P : never {
  return (data as any).someProperty; // Type assertion when necessary
}

// ✅ Better: Use unknown for truly unknown data
function processUnknownData(data: unknown): string {
  if (typeof data === 'object' && data !== null && 'someProperty' in data) {
    return String((data as { someProperty: unknown }).someProperty);
  }
  throw new Error('Invalid data structure');
}

2. Avoid Deep Nesting

// ❌ Bad: Deep nesting
interface DeepNested {
  level1: {
    level2: {
      level3: {
        value: string;
      };
    };
  };
}

// ✅ Good: Flatten with separate interfaces
interface Level3Data {
  value: string;
}

interface Level2Data {
  level3: Level3Data;
}

interface Level1Data {
  level2: Level2Data;
}

interface FlattenedStructure {
  level1: Level1Data;
}

Conclusion

Following these TypeScript best practices will help you build more maintainable, scalable, and robust applications. Remember that TypeScript is not just about adding types—it's about creating a better development experience and catching errors before they reach production.

Key takeaways:

  • Start with strict configuration to catch issues early
  • Use descriptive types that communicate intent
  • Leverage utility types for code reuse
  • Implement proper error handling patterns
  • Optimize for performance with type-only imports
  • Write type-safe tests for better reliability

By incorporating these practices into your development workflow, you'll write TypeScript code that not only works but is also a joy to maintain and extend.


Want to learn some about Next.js? Check out our Exploring Next.js 15 for tips.

Thanks for reading!

Want to read more articles? Check out our blog for the latest insights and updates.