A
Aghyad Alghazawi
1min read

Essential TypeScript patterns and techniques for building robust, type-safe applications.

Mastering TypeScript: Advanced Patterns & Best Practices

TypeScript transforms JavaScript development by catching errors at compile time and enabling better tooling. This guide covers the essential patterns that will elevate your TypeScript skills.

Table of Contents

  1. Essential Utility Types
  2. Type Guards & Conditional Types
  3. Template Literal Types
  4. Branded Types
  5. Generic Patterns
  6. Architectural Patterns

Essential Utility Types

Deep Object Manipulation

// Make all properties optional recursively
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// Extract nested property paths
type Paths<T> = T extends object
  ? {
      [K in keyof T]: K extends string
        ? T[K] extends object
          ? K | `${K}.${Paths<T[K]>}`
          : K
        : never;
    }[keyof T]
  : never;

interface UserProfile {
  id: string;
  personal: {
    name: string;
    email: string;
    preferences: {
      theme: "light" | "dark";
      notifications: boolean;
    };
  };
}

type UserPaths = Paths<UserProfile>;
// Result: "id" | "personal" | "personal.name" | "personal.email" | etc.

Practical Filtering Types

// Filter properties by type
type StringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

interface MixedObject {
  id: number;
  name: string;
  active: boolean;
  description: string;
}

type OnlyStringKeys = StringKeys<MixedObject>; // "name" | "description"

Type Guards & Conditional Types

Smart Type Guards

// Generic type guard for API responses
type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string };

function isSuccessResponse<T>(
  response: ApiResponse<T>,
): response is { success: true; data: T } {
  return response.success === true;
}

// Usage with automatic type narrowing
async function handleApiCall<T>(response: ApiResponse<T>) {
  if (isSuccessResponse(response)) {
    console.log(response.data); // TypeScript knows data exists
  } else {
    console.error(response.error); // TypeScript knows error exists
  }
}

Advanced Conditional Logic

// Extract function return types
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;

// Check if type is a function
type IsFunction<T> = T extends (...args: any[]) => any ? true : false;

// Filter union types
type FilterByType<T, U> = T extends U ? T : never;

Template Literal Types

Type-Safe String Building

// Build API routes with type safety
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiRoute<M extends HttpMethod, P extends string> = `${M} /api/${P}`;

// Event system with typed event names
type EventType = "click" | "hover" | "focus";
type ElementType = "button" | "input" | "div";
type EventName<E extends EventType, T extends ElementType> = `${E}:${T}`;

// Type-safe event emitter
class TypedEventEmitter<T extends Record<string, any[]>> {
  private listeners: { [K in keyof T]?: ((...args: T[K]) => void)[] } = {};

  on<K extends keyof T>(event: K, listener: (...args: T[K]) => void) {
    if (!this.listeners[event]) this.listeners[event] = [];
    this.listeners[event]!.push(listener);
  }

  emit<K extends keyof T>(event: K, ...args: T[K]) {
    this.listeners[event]?.forEach((listener) => listener(...args));
  }
}

// Usage
interface AppEvents {
  "user:login": [userId: string, timestamp: Date];
  "user:logout": [userId: string];
}

const emitter = new TypedEventEmitter<AppEvents>();
emitter.on("user:login", (userId, timestamp) => {
  // TypeScript knows exact parameter types
});

Branded Types

Type-Safe Identifiers

// Create branded types for type safety
declare const __brand: unique symbol;
type Brand<T, TBrand> = T & { [__brand]: TBrand };

type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;

// Type-safe constructors with validation
function createUserId(id: string): UserId {
  if (!id || id.length < 3) throw new Error("Invalid user ID");
  return id as UserId;
}

function createEmail(email: string): Email {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) throw new Error("Invalid email format");
  return email as Email;
}

// Prevents mixing up similar types
function sendWelcomeEmail(userId: UserId, email: Email) {
  // Implementation
}

const id = createUserId("user123");
const email = createEmail("user@example.com");
sendWelcomeEmail(id, email); // Correct
// sendWelcomeEmail(email, id); // TypeScript error

Generic Patterns

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>> {
  try {
    const user = await api.getUser(id);
    return { success: true, data: user };
  } catch (error) {
    return { success: false, error: error as Error };
  }
}

Generic Builder Pattern

class QueryBuilder<T> {
  private conditions: string[] = [];
  private selectFields: (keyof T)[] = [];

  select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {
    this.selectFields = fields;
    return this as any;
  }

  where(condition: string): QueryBuilder<T> {
    this.conditions.push(condition);
    return this;
  }

  build(): string {
    const select =
      this.selectFields.length > 0 ? this.selectFields.join(", ") : "*";
    const where =
      this.conditions.length > 0
        ? ` WHERE ${this.conditions.join(" AND ")}`
        : "";
    return `SELECT ${select} FROM table${where}`;
  }
}

// Usage with type safety
interface User {
  id: string;
  name: string;
  email: string;
}

const query = new QueryBuilder<User>()
  .select("id", "name")
  .where("age > 18")
  .build();

Architectural Patterns

Type-Safe Dependency Injection

// Service container with type safety
class Container {
  private services = new Map<symbol, () => any>();
  private instances = new Map<symbol, any>();

  register<T>(token: symbol, factory: () => T): void {
    this.services.set(token, factory);
  }

  get<T>(token: symbol): T {
    if (this.instances.has(token)) {
      return this.instances.get(token);
    }

    const factory = this.services.get(token);
    if (!factory) throw new Error(`Service not registered`);

    const instance = factory();
    this.instances.set(token, instance);
    return instance;
  }
}

// Service tokens
const TOKENS = {
  UserService: Symbol("UserService"),
  EmailService: Symbol("EmailService"),
} as const;

Event-Driven Architecture

interface DomainEvent {
  type: string;
  timestamp: Date;
  aggregateId: string;
}

interface UserCreatedEvent extends DomainEvent {
  type: "USER_CREATED";
  payload: { userId: string; email: string; name: string };
}

type EventHandler<T extends DomainEvent> = (event: T) => Promise<void> | void;

class EventBus {
  private handlers = new Map<string, EventHandler<any>[]>();

  subscribe<T extends DomainEvent>(
    eventType: T["type"],
    handler: EventHandler<T>,
  ): void {
    if (!this.handlers.has(eventType)) {
      this.handlers.set(eventType, []);
    }
    this.handlers.get(eventType)!.push(handler);
  }

  async publish<T extends DomainEvent>(event: T): Promise<void> {
    const handlers = this.handlers.get(event.type) || [];
    await Promise.all(handlers.map((handler) => handler(event)));
  }
}

Best Practices

Performance Tips

// Use const assertions for better inference
const themes = ["light", "dark"] as const;
type Theme = (typeof themes)[number];

// Prefer type imports for better tree shaking
import type { User } from "./types";

// Use satisfies operator for better type checking
const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
} satisfies { apiUrl: string; timeout: number };

Compilation Optimization

  • Prefer interfaces over type aliases for object shapes
  • Use module augmentation instead of global types
  • Avoid deep recursion in type definitions
  • Leverage const type parameters when available

Conclusion

These TypeScript patterns help you build more reliable applications by catching errors at compile time. Focus on patterns that solve real problems in your codebase rather than using advanced features for their own sake.

The key is finding the right balance between type safety and development velocity. Start with simpler patterns and gradually adopt more advanced techniques as your team becomes comfortable with TypeScript's type system.