TypeScript Best Practices for Large-Scale Applications

TypeScript Best Practices for Large-Scale Applications

Marcus Williams
Marcus Williams
10 min read
July 10, 2023

TypeScript Best Practices for Large-Scale Applications

TypeScript has become the language of choice for many teams building large-scale applications. Its static typing system helps catch errors early, improves code quality, and enhances developer productivity. However, as projects grow in size and complexity, it becomes crucial to adopt best practices to ensure maintainability and performance.

Type System Fundamentals

Leverage TypeScript's Type Inference

TypeScript's type inference is powerful. Use it when it makes your code more readable:

// Let TypeScript infer the type
const users = [
  { id: 1, name: 'Alice', role: 'admin' },
  { id: 2, name: 'Bob', role: 'user' }
];

// Instead of explicitly typing everything
const users: Array<{ id: number; name: string; role: string }> = [
  { id: 1, name: 'Alice', role: 'admin' },
  { id: 2, name: 'Bob', role: 'user' }
];

Use Explicit Types for Function Parameters and Return Types

Always provide explicit types for function parameters and return types to create clear contracts:

// Good
function calculateTotal(items: CartItem[]): number {
  return items.reduce((total, item) => total + item.price * item.quantity, 0);
}

// Avoid
function calculateTotal(items) {
  return items.reduce((total, item) => total + item.price * item.quantity, 0);
}

Utilize Union Types and Type Guards

Union types and type guards create more flexible and type-safe code:

type PaymentMethod = CreditCard | PayPal | BankTransfer;

function processPayment(payment: PaymentMethod): Receipt {
  // Type guard
  if ('cardNumber' in payment) {
    // TypeScript knows payment is CreditCard here
    return processCreditCardPayment(payment);
  } else if ('email' in payment) {
    // TypeScript knows payment is PayPal here
    return processPayPalPayment(payment);
  } else {
    // TypeScript knows payment is BankTransfer here
    return processBankTransferPayment(payment);
  }
}

Project Structure

Organize by Feature, Not by Type

Structure your codebase around business features rather than technical types:

src/
  features/
    auth/
      components/
      hooks/
      services/
      types/
      utils/
      index.ts
    products/
      components/
      hooks/
      services/
      types/
      utils/
      index.ts
  shared/
    components/
    hooks/
    services/
    types/
    utils/

Use Barrel Files to Simplify Imports

Create barrel files (index.ts) to simplify import statements:

// features/auth/index.ts
export * from './components';
export * from './hooks';
export * from './services';
export * from './types';

// In another file
import { LoginForm, useAuth, AuthService, User } from 'features/auth';

Isolate Third-Party Dependencies

Isolate third-party dependencies behind adapters to make future changes easier:

// services/api/axios-adapter.ts
import axios from 'axios';
import type { HttpClient } from './types';

export const axiosHttpClient: HttpClient = {
  get: (url, config) => axios.get(url, config).then(response => response.data),
  post: (url, data, config) => axios.post(url, data, config).then(response => response.data),
  // Other methods...
};

// Use the adapter in your services
import { axiosHttpClient as httpClient } from 'services/api';

Type Definitions

Create Strong Domain Types

Define strong domain types to represent your business entities:

type UserId = string;
type Email = string;
type Role = 'admin' | 'user' | 'guest';

interface User {
  id: UserId;
  email: Email;
  name: string;
  role: Role;
  createdAt: Date;
}

Use Branded Types for Type Safety

Create "branded" or "nominal" types to distinguish between semantically different values with the same primitive type:

type UserId = string & { readonly _brand: unique symbol };
type OrderId = string & { readonly _brand: unique symbol };

function createUserId(id: string): UserId {
  return id as UserId;
}

function createOrderId(id: string): OrderId {
  return id as OrderId;
}

function getUserById(id: UserId) { /* ... */ }

// This will cause a type error
getUserById(createOrderId('123'));

Utilize Utility Types

Take advantage of TypeScript's utility types to transform existing types:

// Make all properties optional
type UserUpdate = Partial<User>;

// Pick specific properties
type UserCredentials = Pick<User, 'email' | 'password'>;

// Omit specific properties
type PublicUser = Omit<User, 'password' | 'securityQuestions'>;

// Extract property types
type UserRole = User['role'];

Error Handling and Nullability

Avoid Implicit any

Configure your tsconfig.json to prevent implicit any types:

{
  "compilerOptions": {
    "noImplicitAny": true
  }
}

Use Discriminated Unions for Error Handling

Create discriminated unions for type-safe error handling:

type Result<T> = 
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const user = await api.users.getById(id);
    return { status: 'success', data: user };
  } catch (error) {
    return { 
      status: 'error', 
      error: error instanceof Error ? error : new Error(String(error)) 
    };
  }
}

const result = await fetchUser('123');
if (result.status === 'success') {
  // TypeScript knows result.data is User
  // Instead of console.log
  // Process the user data
} else {
  // TypeScript knows result.error is Error
  // Instead of console.error
  // Display error to user or retry the operation
}

Avoid Non-Null Assertion Operator When Possible

Minimize use of the non-null assertion operator (!) and handle nullability explicitly:

// Avoid
function getUsername(user?: User): string {
  return user!.name; // Dangerous if user is undefined
}

// Better
function getUsername(user?: User): string {
  if (!user) {
    throw new Error('User is required');
  }
  return user.name;
}

// Or even better
function getUsername(user?: User): string {
  return user?.name ?? 'Anonymous';
}

Performance Considerations

Use readonly for Immutable Data

Mark properties and arrays as readonly when they shouldn't be modified:

interface Config {
  readonly apiUrl: string;
  readonly maxRetries: number;
  readonly timeout: number;
}

function processItems(items: readonly Item[]): number {
  // TypeScript will prevent mutating the array
  return items.reduce((sum, item) => sum + item.value, 0);
}

Optimize Type Checking with type vs interface

Use type for unions, primitives, and tuples. Use interface for objects that might be extended:

// Use type for unions
type Status = 'pending' | 'completed' | 'failed';

// Use interface for extensible objects
interface User {
  id: string;
  name: string;
}

// Can be extended
interface AdminUser extends User {
  permissions: string[];
}

Limit Use of any

Avoid using any as it bypasses type checking. Use unknown instead when the type is truly unknown:

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

// Better: Using unknown with type guards
function parseData(data: unknown): string {
  if (typeof data === 'object' && data !== null && 'someProperty' in data) {
    return String(data.someProperty);
  }
  throw new Error('Invalid data format');
}

Testing

Write Type Tests with @typescript/expect-error

Use @ts-expect-error to test that invalid types are caught:

function add(a: number, b: number): number {
  return a + b;
}

// This should cause a compile-time error
// @ts-expect-error
add('1', '2');

Use Typesafe Testing Libraries

Choose testing libraries with good TypeScript support, like Jest with ts-jest or Vitest:

import { expect, test } from 'vitest';
import { User } from '../types';
import { validateUser } from '../validation';

test('validateUser returns errors for invalid users', () => {
  const invalidUser: Partial<User> = { name: '' };
  const result = validateUser(invalidUser as User);
  expect(result.isValid).toBe(false);
  expect(result.errors).toContain('Name is required');
});

Conclusion

Implementing these TypeScript best practices will help you build large-scale applications that are maintainable, robust, and developer-friendly. Remember that the goal of TypeScript isn't just to add types – it's to create a better developer experience and produce higher-quality code.

As your application grows, revisit these practices and adapt them to your team's specific needs. TypeScript is a powerful tool that can scale with your project, especially when you leverage its type system effectively.