Web Development

Advanced TypeScript Patterns for Large Codebases

February 20, 2025
7 min read

TypeScript has become the de facto standard for large-scale JavaScript applications. But many developers only scratch the surface of what's possible. Let me share advanced patterns that have saved me countless hours of debugging.

Beyond Basic Types

Discriminated Unions

One of the most powerful TypeScript features:

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

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

Template Literal Types

Build type-safe APIs:

type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';
type APIRoute = \`\${HTTPMethod} \${Endpoint}\`;

// Valid: 'GET /users', 'POST /posts', etc.
const route: APIRoute = 'GET /users';

Advanced Utility Types

Deep Partial

Make all properties optional, recursively:

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? DeepPartial<T[P]>
    : T[P];
};

interface Config {
  database: {
    host: string;
    port: number;
  };
}

const partialConfig: DeepPartial<Config> = {
  database: { host: 'localhost' } // port is optional
};

Type-Safe Event Emitters

type EventMap = {
  'user:login': { userId: string; timestamp: number };
  'user:logout': { userId: string };
  'error': { message: string; code: number };
};

class TypedEventEmitter<T extends Record<string, any>> {
  on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {
    // Implementation
  }
  
  emit<K extends keyof T>(event: K, data: T[K]) {
    // Implementation
  }
}

const emitter = new TypedEventEmitter<EventMap>();
emitter.on('user:login', (data) => {
  // data is typed as { userId: string; timestamp: number }
});

Branded Types

Prevent mixing similar types:

type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };

function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const userId = 'user-123' as UserId;
const postId = 'post-456' as PostId;

getUser(userId);  // ✓
getUser(postId);  // ✗ Type error!

Builder Pattern with Type Safety

class QueryBuilder<T, Selected extends keyof T = never> {
  private query: any = {};
  
  select<K extends keyof T>(...fields: K[]) {
    return this as any as QueryBuilder<T, Selected | K>;
  }
  
  where(condition: Partial<T>) {
    this.query.where = condition;
    return this;
  }
  
  execute(): Promise<Pick<T, Selected>[]> {
    return Promise.resolve([]);
  }
}

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

const users = await new QueryBuilder<User>()
  .select('id', 'name')
  .where({ age: 25 })
  .execute();
// users is typed as Array<{ id: string; name: string }>

Conditional Types Magic

Extract Function Return Types

type ExtractPromiseType<T> = T extends Promise<infer U> ? U : T;

async function fetchUser() {
  return { id: '1', name: 'John' };
}

type User = ExtractPromiseType<ReturnType<typeof fetchUser>>;
// { id: string; name: string }

Type-Safe Object Paths

type PathOf<T> = {
  [K in keyof T]: K extends string
    ? T[K] extends object
      ? K | \`\${K}.\${PathOf<T[K]>}\`
      : K
    : never;
}[keyof T];

interface User {
  profile: {
    name: string;
    address: {
      city: string;
    };
  };
}

type UserPath = PathOf<User>;
// 'profile' | 'profile.name' | 'profile.address' | 'profile.address.city'

Strict Configuration

My `tsconfig.json` for large projects:

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

Performance Tips

Avoid Excessive Type Instantiation

// Bad - creates many type instantiations
type Bad<T> = T extends any[] ? T[number] : T;

// Good - use conditional types sparingly
type Good<T> = T extends Array<infer U> ? U : T;

Use Type Assertions Wisely

// Avoid
const user = response.data as User;

// Better - validate first
function isUser(data: unknown): data is User {
  return typeof data === 'object' && 
         data !== null && 
         'id' in data;
}

if (isUser(response.data)) {
  const user = response.data;
}

Real-World Patterns

API Client with Type Safety

interface APIEndpoints {
  '/users': {
    GET: { response: User[] };
    POST: { body: CreateUserDTO; response: User };
  };
  '/users/:id': {
    GET: { params: { id: string }; response: User };
    PUT: { params: { id: string }; body: UpdateUserDTO; response: User };
  };
}

class TypedAPIClient<T extends Record<string, any>> {
  get<Path extends keyof T>(
    path: Path,
    ...args: T[Path] extends { GET: { params: infer P } }
      ? [params: P]
      : []
  ): Promise<T[Path]['GET']['response']> {
    // Implementation
  }
  
  post<Path extends keyof T>(
    path: Path,
    body: T[Path]['POST']['body']
  ): Promise<T[Path]['POST']['response']> {
    // Implementation
  }
}

const client = new TypedAPIClient<APIEndpoints>();

// Fully typed!
const users = await client.get('/users');
const user = await client.get('/users/:id', { id: '123' });

Conclusion

Advanced TypeScript isn't about complexity—it's about encoding your domain logic into the type system so the compiler catches bugs before they reach production.

Start simple, add advanced patterns where they provide clear value, and always prioritize code readability. The goal is developer productivity, not TypeScript gymnastics.

Tags:
TypeScript
Type Safety
Patterns
Enterprise
Best Practices

Want to read more articles?