← All Articles

Advanced TypeScript Patterns for Large Codebases

Exploring advanced type-level programming in TypeScript — branded types, template literal types, conditional types, and patterns that make large codebases maintainable.

TypeScript's type system is Turing-complete. That means you can encode arbitrarily complex logic at the type level. But with great power comes great responsibility. Here are patterns I use daily in production codebases.

Branded Types for Domain Safety

Primitive obsession is a common anti-pattern. A string could be a user ID, an email, or a URL — but the type system can't tell them apart. Branded types fix this:

// Create a unique brand symbol
declare const __brand: unique symbol;

type Brand<T, B extends string> = T & { readonly [__brand]: B };

// Define domain types
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
type URL = Brand<string, "URL">;

// Smart constructors with validation
function createUserId(id: string): UserId {
  if (!/^usr_[a-zA-Z0-9]{24}$/.test(id)) {
    throw new Error(`Invalid user ID format: ${id}`);
  }
  return id as UserId;
}

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

// Now these are type-safe — can't mix them up!
function getUserById(id: UserId): Promise<User> { /* ... */ }
function sendEmail(to: Email, subject: string): void { /* ... */ }

// This would be a compile-time error:
// getUserById(someEmail); // Type 'Email' is not assignable to type 'UserId'

Template Literal Types for API Routes

TypeScript 4.1 introduced template literal types. Combined with mapped types, you can create type-safe API clients:

type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";

type APIRoutes = {
  "GET /users": { response: User[]; query: { limit?: number } };
  "GET /users/:id": { response: User; params: { id: string } };
  "POST /users": { response: User; body: CreateUserDTO };
  "PUT /users/:id": { response: User; params: { id: string }; body: UpdateUserDTO };
  "DELETE /users/:id": { response: void; params: { id: string } };
};

// Extract route parameters from path
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<Rest>]: string }
    : T extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : never;

// Type-safe fetch wrapper
type RouteKey = keyof APIRoutes;

async function apiCall<K extends RouteKey>(
  route: K,
  config: Omit<APIRoutes[K], "response">
): Promise<APIRoutes[K]["response"]> {
  const [method, path] = route.split(" ") as [HTTPMethod, string];
  // Implementation here...
  return {} as APIRoutes[K]["response"];
}

// Usage — fully type-safe!
const users = await apiCall("GET /users", { query: { limit: 10 } });
const user = await apiCall("GET /users/:id", { params: { id: "123" } });

Conditional Types for Polymorphic Components

React components that change their props based on a discriminator are common. Here's how to type them correctly:

type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";

type ButtonBaseProps = {
  variant?: ButtonVariant;
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
  children: React.ReactNode;
};

// Polymorphic "as" prop
type AsProps<E extends React.ElementType> = {
  as?: E;
} & Omit<React.ComponentPropsWithoutRef<E>, keyof ButtonBaseProps>;

type ButtonProps<E extends React.ElementType = "button"> = 
  ButtonBaseProps & AsProps<E>;

function Button<E extends React.ElementType = "button">({
  as,
  variant = "primary",
  size = "md",
  children,
  ...props
}: ButtonProps<E>) {
  const Component = as || "button";
  return <Component className={`btn btn-${variant} btn-${size}`} {...props}>{children}</Component>;
}

// Works as button
<Button onClick={() => {}}>Click me</Button>

// Works as link with full anchor props
<Button as="a" href="/dashboard">Go to Dashboard</Button>

// Works as Next.js Link
<Button as={Link} href="/dashboard">Navigate</Button>

The Builder Pattern with Method Chaining

For complex object construction, the builder pattern with type-safe method chaining ensures you can't forget required fields:

type RequiredFields = "name" | "email" | "role";

type BuilderState = {
  [K in RequiredFields]: boolean;
};

class UserBuilder<State extends Partial<BuilderState> = {}> {
  private data: Partial<User> = {};

  name(name: string): UserBuilder<State & { name: true }> {
    this.data.name = name;
    return this as any;
  }

  email(email: string): UserBuilder<State & { email: true }> {
    this.data.email = email;
    return this as any;
  }

  role(role: Role): UserBuilder<State & { role: true }> {
    this.data.role = role;
    return this as any;
  }

  // build() is only available when ALL required fields are set
  build(
    this: UserBuilder<{ [K in RequiredFields]: true }>
  ): User {
    return this.data as User;
  }
}

// This works:
const user = new UserBuilder()
  .name("Mukesh")
  .email("mukesh@example.com")
  .role("admin")
  .build(); // OK!

// This fails at compile time:
// new UserBuilder().name("Mukesh").build();
// Error: Property 'build' does not exist

Exhaustive Pattern Matching

Ensure you handle every case in a discriminated union:

type Result<T, E = Error> =
  | { status: "success"; data: T }
  | { status: "error"; error: E }
  | { status: "loading" }
  | { status: "idle" };

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}

function renderResult<T>(result: Result<T>): string {
  switch (result.status) {
    case "success": return `Data: ${result.data}`;
    case "error": return `Error: ${result.error.message}`;
    case "loading": return "Loading...";
    case "idle": return "Ready";
    default: return assertNever(result);
    // If you add a new status, TypeScript will error here
  }
}

Conclusion

These patterns might seem complex in isolation, but they pay dividends at scale. When your codebase grows to hundreds of files and dozens of contributors, the compiler becomes your most reliable code reviewer.

The best TypeScript code reads like documentation and fails like a safety net.

Type-level programming isn't about showing off — it's about making invalid states unrepresentable.

Next ArticleZero-Downtime Deployments on KubernetesDevOps · 10 min read