¯ cat typescript-best-practices.mdx

TypeScript Best Practices for Large-Scale Applications

📅
⏱️4 min read
#typescript#javascript#best-practices#architecture

Learn advanced TypeScript patterns and techniques to build maintainable, type-safe applications at scale

Introduction

TypeScript has become the de facto standard for building large-scale JavaScript applications. However, leveraging its full potential requires understanding advanced patterns and best practices. Let's explore how to write better TypeScript code.

Type Safety First

Avoid any Like the Plague

typescript
// ❌ Bad
function processData(data: any) {
  return data.value;
}

// ✅ Good
interface DataStructure {
  value: string;
  metadata: Record<string, unknown>;
}

function processData(data: DataStructure) {
  return data.value;
}

Use unknown for True Unknowns

typescript
function parseJSON(jsonString: string): unknown {
  return JSON.parse(jsonString);
}

// Force type checking before use
const result = parseJSON('{"name": "John"}');
if (typeof result === 'object' && result !== null && 'name' in result) {
  console.log(result.name);
}

Advanced Types

Utility Types

TypeScript provides powerful utility types:

typescript
interface User {
  id: string;
  name: string;
  email: string;
  age: number;
}

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

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

// Make all properties required
type RequiredUser = Required<Partial<User>>;

// Exclude properties
type UserWithoutAge = Omit<User, 'age'>;

Discriminated Unions

Create type-safe state machines:

typescript
type LoadingState = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; error: Error };

function handleState(state: LoadingState) {
  switch (state.status) {
    case 'idle':
      return 'Ready to start';
    case 'loading':
      return 'Loading...';
    case 'success':
      return `Data: ${state.data}`; // TypeScript knows data exists
    case 'error':
      return `Error: ${state.error.message}`; // TypeScript knows error exists
  }
}

Generic Constraints

Make your generics more powerful:

typescript
// Constrain to objects with an id property
interface HasId {
  id: string;
}

function findById<T extends HasId>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}

// Usage
const users = [{ id: '1', name: 'John' }, { id: '2', name: 'Jane' }];
const user = findById(users, '1'); // Type: { id: string; name: string } | undefined

Conditional Types

Create dynamic types based on conditions:

typescript
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

// Practical example: Extract promise type
type Awaited<T> = T extends Promise<infer U> ? U : T;

type Result = Awaited<Promise<string>>;  // string

Template Literal Types

Build powerful string types:

typescript
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';

type APIRoute = `${HTTPMethod} ${Endpoint}`;
// Results in: 'GET /users' | 'GET /posts' | 'GET /comments' | 'POST /users' | ...

// Route handler
function handleRequest(route: APIRoute) {
  // Type-safe routing
}

Branded Types

Prevent mixing similar primitive types:

typescript
type UserId = string & { readonly __brand: 'UserId' };
type ProductId = string & { readonly __brand: 'ProductId' };

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

function getUserById(id: UserId) {
  // Fetch user
}

const userId = createUserId('user-123');
const productId = 'product-456' as ProductId;

getUserById(userId); // ✅ Works
getUserById(productId); // ❌ Type error!

Error Handling

Type-safe error handling:

typescript
class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number
  ) {
    super(message);
    this.name = 'AppError';
  }
}

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

async function fetchUser(id: string): Promise<Result<User, AppError>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    const user = await response.json();
    return { success: true, value: user };
  } catch (error) {
    return {
      success: false,
      error: new AppError('Failed to fetch user', 'FETCH_ERROR', 500)
    };
  }
}

Strict Configuration

Always use strict mode in tsconfig.json:

json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true
  }
}

Performance Tips

  1. Use const assertions for literal types:
typescript
const routes = ['/', '/about', '/contact'] as const;
type Route = typeof routes[number]; // '/' | '/about' | '/contact'
  1. Avoid excessive type complexity:
typescript
// Consider splitting complex types
type ComplexType = Omit<Pick<User, 'id' | 'name'>, never> & { metadata: unknown };
// Better: Create intermediate types
type UserBasics = Pick<User, 'id' | 'name'>;
type ComplexType = UserBasics & { metadata: unknown };

Testing with Types

typescript
import { expectType } from 'tsd';

// Type-level tests
expectType<string>(getUserName());
expectType<number>(getUserAge());

Conclusion

Mastering TypeScript's advanced features enables you to build robust, maintainable applications. Start incorporating these patterns into your codebase today!

Key Takeaways

  • Avoid any, use unknown when type is truly unknown
  • Leverage utility types and discriminated unions
  • Use branded types for type safety with primitives
  • Enable strict mode for maximum type safety
  • Write type-level tests for critical code paths