Advanced TypeScript Patterns: Beyond Types and Interfaces

12 min read23rd July, 2025

While most developers know TypeScript for its basic type annotations and interfaces, the real power lies in its advanced type system features that can transform how you build applications. From enterprise codebases to cutting-edge startups, teams are leveraging these sophisticated patterns to create bulletproof APIs, eliminate entire classes of runtime errors, and build systems that scale effortlessly.

This guide dives deep into the TypeScript patterns that separate good developers from great ones. You'll discover techniques that make your code not just type-safe, but elegant, maintainable, and surprisingly expressive.

The Foundation: Why Advanced Patterns Matter

Before diving into specific techniques, it's crucial to understand why these patterns exist. TypeScript's type system is Turing-complete, meaning you can perform complex computations at the type level. This allows you to encode business logic, validation rules, and architectural constraints directly into your type definitions.

The result? Bugs that would normally surface in production are caught at compile time. API misuse becomes impossible. Code becomes self-documenting. And refactoring large codebases becomes a confidence-inspiring experience rather than a nail-biting adventure.

1. Advanced Type Utilities That Actually Matter

Most developers stop at basic type annotations, but TypeScript's built-in utilities can eliminate hundreds of lines of boilerplate code.

// Instead of manual property picking
interface UserProfile {
  id: string;
  name: string;
  email: string;
  avatar: string;
  preferences: UserPreferences;
  createdAt: Date;
  lastLogin: Date;
}

interface UserCard {
  name: string;
  email: string;
  avatar: string;
}

interface UserSummary {
  id: string;
  name: string;
  email: string;
}

// Use built-in utilities to derive types
type UserCard = Pick<UserProfile, "name" | "email" | "avatar">;
type UserSummary = Pick<UserProfile, "id" | "name" | "email">;
type OptionalUser = Partial<UserProfile>;
type RequiredUser = Required<UserProfile>;
type UserWithoutDates = Omit<UserProfile, "createdAt" | "lastLogin">;

These utilities don't just save typing—they create a single source of truth. When your UserProfile interface changes, all derived types automatically stay in sync. No more hunting through codebases to update related interfaces.

Pro tip: Create custom utilities for your domain-specific needs:

// Create a utility that makes certain fields required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;

// Usage: Ensure email is always present in user registration
type UserRegistration = RequireFields<Partial<UserProfile>, "email" | "name">;

2. Generic Constraints: The Art of Flexible Boundaries

Generics without constraints are like cars without brakes—powerful but dangerous. Constraints give you the perfect balance of flexibility and safety.

// Weak: accepts any object, provides no safety
function updateEntity<T>(entity: T, updates: any): T {
  return { ...entity, ...updates };
}

// Strong: ensures updates match entity structure
function updateEntity<T extends Record<string, any>>(
  entity: T,
  updates: Partial<T>
): T {
  return { ...entity, ...updates };
}

// Even stronger: constrain to objects with an id field
function updateEntityById<T extends { id: string | number }>(
  entity: T,
  updates: Partial<Omit<T, "id">>
): T {
  return { ...entity, ...updates };
}

// Usage gets full IntelliSense and type safety
const user = { id: 1, name: "John", email: "john@example.com", role: "admin" };
const updated = updateEntityById(user, {
  name: "Jane",
  // id: 2 // ❌ Compiler error - can't update id!
  // invalidField: "test" // ❌ Compiler error - field doesn't exist!
});

Generic constraints shine in API design where you need flexibility without chaos. They let you create functions that work with many types while maintaining strict safety guarantees.

3. Discriminated Unions: Type-Safe Error Handling

Stop pretending errors won't happen. Discriminated unions force you to handle every possible outcome explicitly, eliminating the "it worked on my machine" problem.

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

// Alternative pattern with status codes
type ApiResult<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string }
  | { status: "empty" };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      return {
        success: false,
        error: new Error(`HTTP ${response.status}: ${response.statusText}`),
      };
    }
    const user = await response.json();
    return { success: true, data: user };
  } catch (error) {
    return { success: false, error: error as Error };
  }
}

// Usage forces explicit error handling
async function handleUserFetch(id: string) {
  const result = await fetchUser(id);

  if (result.success) {
    // TypeScript knows result.data exists and is User type
    console.log(`Welcome, ${result.data.name}!`);
    // result.error // ❌ Compiler error - error doesn't exist in success case
  } else {
    // TypeScript knows result.error exists and is Error type
    console.error(`Failed to fetch user: ${result.error.message}`);
    // result.data // ❌ Compiler error - data doesn't exist in error case
  }
}

This pattern eliminates a huge class of bugs. No more forgotten error handling, no more assuming operations will succeed, no more Cannot read property of undefined errors.

4. Template Literal Types: Code That Writes Itself

Template literal types let you generate complex type definitions from simple patterns. It's like having a macro system built into the type checker.

// Basic example: HTTP routes
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type ApiEndpoint = "/users" | "/posts" | "/comments" | "/auth";
type ApiRoute = `${HttpMethod} ${ApiEndpoint}`;
// Result: 'GET /users' | 'POST /users' | 'PUT /users' | ... (20 total combinations)

// Real-world example: CSS utility classes
type CSSProperty = "margin" | "padding";
type CSSDirection = "top" | "right" | "bottom" | "left" | "x" | "y";
type CSSSize = "0" | "1" | "2" | "4" | "8" | "16" | "32";
type CSSUtility = `${CSSProperty}-${CSSDirection}-${CSSSize}`;

// Generate a theme with all combinations
const spacing: Record<CSSUtility, string> = {
  "margin-top-0": "0px",
  "margin-top-1": "0.25rem",
  "padding-x-4": "1rem",
  // TypeScript ensures all valid combinations are available
};

// Advanced: Event system with type safety
type EventMap = {
  "user:login": { userId: string; timestamp: Date };
  "user:logout": { userId: string };
  "order:created": { orderId: string; amount: number };
  "order:cancelled": { orderId: string; reason: string };
};

type EventName = keyof EventMap;
type EventHandler<T extends EventName> = (data: EventMap[T]) => void;

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

  on<T extends EventName>(event: T, handler: EventHandler<T>) {
    const eventHandlers = this.handlers.get(event) || [];
    eventHandlers.push(handler);
    this.handlers.set(event, eventHandlers);
  }

  emit<T extends EventName>(event: T, data: EventMap[T]) {
    const eventHandlers = this.handlers.get(event) || [];
    eventHandlers.forEach((handler) => handler(data));
  }
}

// Usage with full type safety
const emitter = new TypeSafeEventEmitter();

emitter.on("user:login", (data) => {
  // data is automatically typed as { userId: string; timestamp: Date }
  console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});

emitter.emit("user:login", {
  userId: "123",
  timestamp: new Date(),
  // extraField: "test" // ❌ Compiler error - extra fields not allowed
});

Template literal types turn TypeScript into a code generation tool, creating hundreds of precisely typed definitions from concise patterns.

5. Conditional Types: Types That Think

Conditional types let you create logic at the type level. They examine input types and produce different outputs based on conditions—like if statements for your type system.

// Basic conditional type
type IsString<T> = T extends string ? true : false;

type Test1 = IsString<string>; // true
type Test2 = IsString<number>; // false

// Practical example: API response shaping
type ApiResponse<T> = T extends string
  ? { message: T; type: "text" }
  : T extends number
  ? { count: T; type: "numeric" }
  : T extends boolean
  ? { success: T; type: "boolean" }
  : { data: T; type: "object" };

// Usage automatically infers the correct response shape
function createResponse<T>(value: T): ApiResponse<T> {
  if (typeof value === "string") {
    return { message: value, type: "text" } as ApiResponse<T>;
  }
  if (typeof value === "number") {
    return { count: value, type: "numeric" } as ApiResponse<T>;
  }
  if (typeof value === "boolean") {
    return { success: value, type: "boolean" } as ApiResponse<T>;
  }
  return { data: value, type: "object" } as ApiResponse<T>;
}

// Advanced: Extract return type from async functions
type AsyncReturnType<T> = T extends (...args: any[]) => Promise<infer R>
  ? R
  : T extends (...args: any[]) => infer R
  ? R
  : never;

async function fetchUserData(): Promise<{ name: string; email: string }> {
  // implementation details...
  return { name: "John", email: "john@example.com" };
}

function getUserId(): string {
  return "user-123";
}

type UserData = AsyncReturnType<typeof fetchUserData>; // { name: string; email: string }
type UserId = AsyncReturnType<typeof getUserId>; // string

// Ultra-advanced: Deep partial for nested objects
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface NestedConfig {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  api: {
    baseUrl: string;
    timeout: number;
  };
}

// All properties at every level become optional
type PartialConfig = DeepPartial<NestedConfig>;

Conditional types let you encode complex business logic directly into your type system, making impossible states truly impossible to represent.

6. Branded Types: Domain-Driven Type Safety

Branded types prevent you from mixing up primitive values that happen to have the same underlying type but represent completely different concepts.

// The problem: primitive obsession
function transferMoney(from: string, to: string, amount: number) {
  // Easy to accidentally swap parameters
}

transferMoney("user123", "user456", 100); // Correct
transferMoney("user456", "user123", 100); // Oops, backwards!

// The solution: branded types
type UserId = string & { readonly __brand: "UserId" };
type AccountId = string & { readonly __brand: "AccountId" };
type MoneyAmount = number & { readonly __brand: "MoneyAmount" };
type Email = string & { readonly __brand: "Email" };

// Smart 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 {
  if (!email.includes("@") || !email.includes(".")) {
    throw new Error("Invalid email format");
  }
  return email as Email;
}

function createMoneyAmount(amount: number): MoneyAmount {
  if (amount < 0 || !Number.isFinite(amount)) {
    throw new Error("Invalid money amount");
  }
  return amount as MoneyAmount;
}

// Now the function signature is bulletproof
function transferMoney(from: UserId, to: UserId, amount: MoneyAmount) {
  console.log(`Transferring $${amount} from ${from} to ${to}`);
}

// Usage requires explicit construction
const sender = createUserId("user123");
const recipient = createUserId("user456");
const amount = createMoneyAmount(100);

transferMoney(sender, recipient, amount); // ✅ Type-safe
// transferMoney(recipient, sender, amount); // Still possible to swap, but now it's intentional
// transferMoney("user123", "user456", 100); // ❌ Compiler error!

// Advanced: Branded types with additional metadata
type ValidatedEmail = string & {
  readonly __brand: "ValidatedEmail";
  readonly domain: string;
  readonly localPart: string;
};

function createValidatedEmail(email: string): ValidatedEmail {
  const [localPart, domain] = email.split("@");
  if (!localPart || !domain) {
    throw new Error("Invalid email format");
  }

  const validatedEmail = email as ValidatedEmail;
  (validatedEmail as any).domain = domain;
  (validatedEmail as any).localPart = localPart;

  return validatedEmail;
}

Branded types transform runtime validation into compile-time guarantees, catching domain logic errors before they reach production.

7. React Integration: Type-Safe Component Patterns

TypeScript and React form a powerful combination when you leverage advanced patterns for component design.

// Extract props from existing components for consistency
type ButtonProps = React.ComponentProps<"button">;
type DivProps = React.ComponentProps<"div">;
type InputProps = React.ComponentProps<"input">;

// Create flexible component APIs with proper inheritance
interface CustomButtonProps extends Omit<ButtonProps, "onClick" | "children"> {
  variant?: "primary" | "secondary" | "danger" | "ghost";
  size?: "sm" | "md" | "lg";
  isLoading?: boolean;
  children: React.ReactNode;
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>;
}

function CustomButton({
  variant = "primary",
  size = "md",
  isLoading = false,
  children,
  onClick,
  disabled,
  ...props
}: CustomButtonProps) {
  const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
    if (onClick) {
      await onClick(event);
    }
  };

  return (
    <button
      {...props}
      onClick={handleClick}
      disabled={disabled || isLoading}
      className={`btn btn-${variant} btn-${size} ${isLoading ? 'loading' : ''}`}
    >
      {isLoading ? <Spinner /> : children}
    </button>
  );
}

// Polymorphic components: same component, different HTML elements
type PolymorphicProps<T extends React.ElementType> = {
  as?: T;
  children: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;

function Text<T extends React.ElementType = "p">({
  as,
  children,
  ...props
}: PolymorphicProps<T>) {
  const Component = as || "p";
  return <Component {...props}>{children}</Component>;
}

// Usage: fully type-safe polymorphism
<Text>Default paragraph</Text>
<Text as="h1">Heading with h1 semantics</Text>
<Text as="span" onClick={(e) => console.log("Clicked!")}>Clickable span</Text>
<Text as="a" href="/link">Link with proper href validation</Text>

// Advanced: Form components with validation
type FormFieldProps<T> = {
  name: keyof T;
  label: string;
  error?: string;
  required?: boolean;
};

type TextFieldProps<T> = FormFieldProps<T> & {
  type?: "text" | "email" | "password";
  value: string;
  onChange: (value: string) => void;
};

function TextField<T>({
  name,
  label,
  error,
  required,
  type = "text",
  value,
  onChange,
}: TextFieldProps<T>) {
  return (
    <div className="form-field">
      <label htmlFor={String(name)}>
        {label} {required && <span className="required">*</span>}
      </label>
      <input
        id={String(name)}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        className={error ? "error" : ""}
        required={required}
      />
      {error && <span className="error-message">{error}</span>}
    </div>
  );
}

// Usage with full type safety
interface UserForm {
  name: string;
  email: string;
  password: string;
}

function UserRegistration() {
  const [form, setForm] = useState<UserForm>({ name: "", email: "", password: "" });
  const [errors, setErrors] = useState<Partial<Record<keyof UserForm, string>>>({});

  return (
    <form>
      <TextField<UserForm>
        name="name" // ✅ Must be a key of UserForm
        label="Full Name"
        value={form.name}
        onChange={(value) => setForm(prev => ({ ...prev, name: value }))}
        error={errors.name}
        required
      />
      <TextField<UserForm>
        name="email"
        type="email"
        label="Email Address"
        value={form.email}
        onChange={(value) => setForm(prev => ({ ...prev, email: value }))}
        error={errors.email}
        required
      />
      {/* TypeScript ensures all fields are handled correctly */}
    </form>
  );
}

These patterns create React components that are impossible to misuse, with IntelliSense that guides developers toward correct usage.

8. Configuration Management: Bulletproof App Setup

Configuration bugs are silent killers. They work in development, fail in production, and are notoriously hard to debug. TypeScript can eliminate them entirely.

// Define your complete application configuration
interface DatabaseConfig {
  host: string;
  port: number;
  database: string;
  username: string;
  password: string;
  ssl: boolean;
  connectionTimeout: number;
  queryTimeout: number;
}

interface ApiConfig {
  baseUrl: string;
  timeout: number;
  retries: number;
  rateLimit: {
    windowMs: number;
    maxRequests: number;
  };
}

interface AuthConfig {
  jwtSecret: string;
  sessionDuration: number;
  refreshTokenExpiry: number;
  passwordMinLength: number;
}

interface FeatureFlags {
  readonly [(K in "authentication") |
    "analytics" |
    "notifications" |
    "beta_features"]: boolean;
}

// Master configuration interface
interface AppConfig {
  environment: "development" | "staging" | "production";
  database: DatabaseConfig;
  api: ApiConfig;
  auth: AuthConfig;
  features: FeatureFlags;
  logging: {
    level: "debug" | "info" | "warn" | "error";
    enableConsole: boolean;
    enableFile: boolean;
  };
}

// Configuration validation with detailed error messages
function validateConfig(): AppConfig {
  const env = process.env;

  // Environment validation
  const environment = env.NODE_ENV;
  if (
    !environment ||
    !["development", "staging", "production"].includes(environment)
  ) {
    throw new Error(
      "NODE_ENV must be one of: development, staging, production"
    );
  }

  // Database configuration
  const database: DatabaseConfig = {
    host: env.DB_HOST || "localhost",
    port: parseInt(env.DB_PORT || "5432", 10),
    database:
      env.DB_NAME ||
      (() => {
        throw new Error("DB_NAME is required");
      })(),
    username:
      env.DB_USER ||
      (() => {
        throw new Error("DB_USER is required");
      })(),
    password:
      env.DB_PASSWORD ||
      (() => {
        throw new Error("DB_PASSWORD is required");
      })(),
    ssl: env.DB_SSL === "true",
    connectionTimeout: parseInt(env.DB_CONNECTION_TIMEOUT || "30000", 10),
    queryTimeout: parseInt(env.DB_QUERY_TIMEOUT || "60000", 10),
  };

  if (isNaN(database.port) || database.port < 1 || database.port > 65535) {
    throw new Error("DB_PORT must be a valid port number");
  }

  // API configuration
  const api: ApiConfig = {
    baseUrl:
      env.API_BASE_URL ||
      (() => {
        throw new Error("API_BASE_URL is required");
      })(),
    timeout: parseInt(env.API_TIMEOUT || "5000", 10),
    retries: parseInt(env.API_RETRIES || "3", 10),
    rateLimit: {
      windowMs: parseInt(env.RATE_LIMIT_WINDOW_MS || "900000", 10), // 15 minutes
      maxRequests: parseInt(env.RATE_LIMIT_MAX_REQUESTS || "100", 10),
    },
  };

  // Authentication configuration
  const auth: AuthConfig = {
    jwtSecret:
      env.JWT_SECRET ||
      (() => {
        throw new Error("JWT_SECRET is required");
      })(),
    sessionDuration: parseInt(env.SESSION_DURATION || "3600", 10), // 1 hour
    refreshTokenExpiry: parseInt(env.REFRESH_TOKEN_EXPIRY || "604800", 10), // 1 week
    passwordMinLength: parseInt(env.PASSWORD_MIN_LENGTH || "8", 10),
  };

  if (auth.jwtSecret.length < 32) {
    throw new Error("JWT_SECRET must be at least 32 characters long");
  }

  // Feature flags
  const features: FeatureFlags = {
    authentication: env.FEATURE_AUTH !== "false",
    analytics: env.FEATURE_ANALYTICS === "true",
    notifications: env.FEATURE_NOTIFICATIONS === "true",
    beta_features: env.FEATURE_BETA === "true" && environment !== "production",
  };

  // Logging configuration
  const logging = {
    level: (env.LOG_LEVEL as AppConfig["logging"]["level"]) || "info",
    enableConsole: env.LOG_CONSOLE !== "false",
    enableFile: env.LOG_FILE === "true",
  };

  return {
    environment: environment as AppConfig["environment"],
    database,
    api,
    auth,
    features,
    logging,
  };
}

// Export validated configuration
export const config = validateConfig();

// Usage throughout your application
console.log(`Starting in ${config.environment} mode`);
console.log(`Database: ${config.database.host}:${config.database.port}`);
console.log(
  `Features enabled:`,
  Object.entries(config.features)
    .filter(([, enabled]) => enabled)
    .map(([feature]) => feature)
);

// Type-safe feature flag checking
if (config.features.analytics) {
  // Analytics code here - TypeScript knows this is conditionally executed
}

This approach catches configuration errors at startup rather than during critical operations, and provides clear error messages for debugging.

9. Type-Safe Environment Variables

Environment variables are strings by default, but your application needs typed, validated configuration. Here's how to bridge that gap safely.

// Define expected environment variables with their types
interface EnvironmentSchema {
  // Required variables
  NODE_ENV: "development" | "production" | "test";
  DATABASE_URL: string;
  API_KEY: string;
  JWT_SECRET: string;

  // Optional variables with defaults
  PORT?: number;
  LOG_LEVEL?: "debug" | "info" | "warn" | "error";
  FEATURE_FLAGS?: string;
  MAX_UPLOAD_SIZE?: number;

  // Boolean flags
  ENABLE_CORS?: boolean;
  DEBUG_MODE?: boolean;
  ENABLE_SWAGGER?: boolean;
}

// Validation functions for different types
const validators = {
  string: (value: string | undefined, name: string): string => {
    if (!value) throw new Error(`${name} is required`);
    return value;
  },

  number: (
    value: string | undefined,
    name: string,
    defaultValue?: number
  ): number => {
    if (!value) {
      if (defaultValue !== undefined) return defaultValue;
      throw new Error(`${name} is required`);
    }
    const parsed = parseInt(value, 10);
    if (isNaN(parsed)) throw new Error(`${name} must be a number`);
    return parsed;
  },

  boolean: (
    value: string | undefined,
    defaultValue: boolean = false
  ): boolean => {
    if (!value) return defaultValue;
    return value.toLowerCase() === "true";
  },

  enum: <T extends string>(
    value: string | undefined,
    name: string,
    options: readonly T[],
    defaultValue?: T
  ): T => {
    if (!value) {
      if (defaultValue) return defaultValue;
      throw new Error(`${name} is required`);
    }
    if (!options.includes(value as T)) {
      throw new Error(`${name} must be one of: ${options.join(", ")}`);
    }
    return value as T;
  },

  url: (value: string | undefined, name: string): string => {
    if (!value) throw new Error(`${name} is required`);
    try {
      new URL(value);
      return value;
    } catch {
      throw new Error(`${name} must be a valid URL`);
    }
  },

  json: <T>(value: string | undefined, name: string, defaultValue?: T): T => {
    if (!value) {
      if (defaultValue !== undefined) return defaultValue;
      throw new Error(`${name} is required`);
    }
    try {
      return JSON.parse(value);
    } catch {
      throw new Error(`${name} must be valid JSON`);
    }
  },
};

// Main validation function
function validateEnvironment(): Required<EnvironmentSchema> {
  const env = process.env;

  try {
    return {
      NODE_ENV: validators.enum(env.NODE_ENV, "NODE_ENV", [
        "development",
        "production",
        "test",
      ] as const),

      DATABASE_URL: validators.url(env.DATABASE_URL, "DATABASE_URL"),

      API_KEY: validators.string(env.API_KEY, "API_KEY"),

      JWT_SECRET: (() => {
        const secret = validators.string(env.JWT_SECRET, "JWT_SECRET");
        if (secret.length < 32) {
          throw new Error("JWT_SECRET must be at least 32 characters");
        }
        return secret;
      })(),

      PORT: validators.number(env.PORT, "PORT", 3000),

      LOG_LEVEL: validators.enum(
        env.LOG_LEVEL,
        "LOG_LEVEL",
        ["debug", "info", "warn", "error"] as const,
        "info"
      ),

      FEATURE_FLAGS: validators.json(
        env.FEATURE_FLAGS,
        "FEATURE_FLAGS",
        JSON.stringify({})
      ),

      MAX_UPLOAD_SIZE: validators.number(
        env.MAX_UPLOAD_SIZE,
        "MAX_UPLOAD_SIZE",
        10485760
      ), // 10MB

      ENABLE_CORS: validators.boolean(env.ENABLE_CORS, true),

      DEBUG_MODE: validators.boolean(
        env.DEBUG_MODE,
        env.NODE_ENV === "development"
      ),

      ENABLE_SWAGGER: validators.boolean(
        env.ENABLE_SWAGGER,
        env.NODE_ENV !== "production"
      ),
    };
  } catch (error) {
    console.error("Environment validation failed:", error);
    process.exit(1);
  }
}

// Export validated environment
export const env = validateEnvironment();

// Usage examples with full type safety
console.log(`Server starting on port ${env.PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
console.log(`Debug mode: ${env.DEBUG_MODE ? "enabled" : "disabled"}`);

// Type-safe feature flag checking
const featureFlags = env.FEATURE_FLAGS as Record<string, boolean>;
if (featureFlags.newFeature) {
  console.log("New feature is enabled");
}

// Configuration-dependent imports
if (env.ENABLE_SWAGGER) {
  // Only import swagger in environments where it's enabled
  import("swagger-ui-express").then(/* setup swagger */);
}

This pattern catches environment configuration errors immediately at startup, provides clear error messages, and gives you fully typed access to all configuration throughout your application.

10. Advanced Pattern: Type-Safe State Machines

State management is one of the hardest problems in software development. Type-safe state machines eliminate entire categories of bugs by making invalid states impossible to represent.

// Define all possible states explicitly
type LoadingState = {
  status: "loading";
  progress?: number;
  message?: string;
};

type SuccessState = {
  status: "success";
  data: User[];
  loadedAt: Date;
  totalCount: number;
};

type ErrorState = {
  status: "error";
  error: string;
  retryCount: number;
  canRetry: boolean;
};

type EmptyState = {
  status: "empty";
  message: string;
};

type IdleState = {
  status: "idle";
};

// Union of all possible states
type AsyncState =
  | LoadingState
  | SuccessState
  | ErrorState
  | EmptyState
  | IdleState;

// Define all possible actions
type AsyncAction =
  | { type: "FETCH_START"; payload?: { message?: string } }
  | { type: "FETCH_SUCCESS"; payload: { data: User[]; totalCount: number } }
  | { type: "FETCH_ERROR"; payload: { error: string; canRetry: boolean } }
  | { type: "FETCH_EMPTY"; payload: { message: string } }
  | { type: "RETRY" }
  | { type: "RESET" };

// State machine reducer with exhaustive pattern matching
function asyncReducer(state: AsyncState, action: AsyncAction): AsyncState {
  switch (action.type) {
    case "FETCH_START":
      return {
        status: "loading",
        progress: 0,
        message: action.payload?.message || "Loading...",
      };

    case "FETCH_SUCCESS":
      return {
        status: "success",
        data: action.payload.data,
        loadedAt: new Date(),
        totalCount: action.payload.totalCount,
      };

    case "FETCH_ERROR":
      const retryCount = state.status === "error" ? state.retryCount + 1 : 1;
      return {
        status: "error",
        error: action.payload.error,
        retryCount,
        canRetry: action.payload.canRetry && retryCount < 3,
      };

    case "FETCH_EMPTY":
      return {
        status: "empty",
        message: action.payload.message,
      };

    case "RETRY":
      if (state.status === "error" && state.canRetry) {
        return { status: "loading", message: "Retrying..." };
      }
      return state; // Invalid transition, no change

    case "RESET":
      return { status: "idle" };

    default:
      // TypeScript ensures this case is never reached
      const exhaustiveCheck: never = action;
      return state;
  }
}

// React hook that uses the state machine
function useAsyncData<T>(
  fetchFn: () => Promise<T[]>,
  options: {
    emptyMessage?: string;
    errorRetryable?: boolean;
  } = {}
) {
  const [state, dispatch] = useReducer(asyncReducer, { status: "idle" });

  const fetch = useCallback(async () => {
    dispatch({ type: "FETCH_START" });

    try {
      const data = await fetchFn();

      if (data.length === 0) {
        dispatch({
          type: "FETCH_EMPTY",
          payload: { message: options.emptyMessage || "No data found" },
        });
      } else {
        dispatch({
          type: "FETCH_SUCCESS",
          payload: { data: data as User[], totalCount: data.length },
        });
      }
    } catch (error) {
      dispatch({
        type: "FETCH_ERROR",
        payload: {
          error: error instanceof Error ? error.message : "Unknown error",
          canRetry: options.errorRetryable !== false,
        },
      });
    }
  }, [fetchFn, options.emptyMessage, options.errorRetryable]);

  const retry = useCallback(() => {
    if (state.status === "error" && state.canRetry) {
      fetch();
    }
  }, [state, fetch]);

  const reset = useCallback(() => {
    dispatch({ type: "RESET" });
  }, []);

  return { state, fetch, retry, reset };
}

// Component that uses the state machine
function UserList() {
  const { state, fetch, retry, reset } = useAsyncData(
    () => fetch("/api/users").then((res) => res.json()),
    {
      emptyMessage: "No users found. Try adding some users first.",
      errorRetryable: true,
    }
  );

  // TypeScript enforces handling of all possible states
  const renderContent = () => {
    switch (state.status) {
      case "idle":
        return (
          <div className="text-center">
            <p>Ready to load users</p>
            <button onClick={fetch} className="btn-primary">
              Load Users
            </button>
          </div>
        );

      case "loading":
        return (
          <div className="loading-container">
            <Spinner />
            <p>{state.message}</p>
            {state.progress !== undefined && (
              <ProgressBar progress={state.progress} />
            )}
          </div>
        );

      case "success":
        return (
          <div>
            <div className="success-header">
              <h2>Users ({state.totalCount})</h2>
              <p>Last updated: {state.loadedAt.toLocaleString()}</p>
              <button onClick={fetch} className="btn-secondary">
                Refresh
              </button>
            </div>
            <div className="user-grid">
              {state.data.map((user) => (
                <UserCard key={user.id} user={user} />
              ))}
            </div>
          </div>
        );

      case "error":
        return (
          <div className="error-container">
            <ErrorIcon />
            <h3>Failed to load users</h3>
            <p>{state.error}</p>
            <div className="error-actions">
              {state.canRetry ? (
                <button onClick={retry} className="btn-primary">
                  Retry {state.retryCount > 1 && `(${state.retryCount}/3)`}
                </button>
              ) : (
                <p>Maximum retry attempts reached</p>
              )}
              <button onClick={reset} className="btn-secondary">
                Start Over
              </button>
            </div>
          </div>
        );

      case "empty":
        return (
          <div className="empty-container">
            <EmptyStateIcon />
            <h3>No Users Found</h3>
            <p>{state.message}</p>
            <button onClick={fetch} className="btn-primary">
              Try Again
            </button>
          </div>
        );

      default:
        // TypeScript ensures this is never reached
        const exhaustiveCheck: never = state;
        return null;
    }
  };

  return <div className="user-list">{renderContent()}</div>;
}

// Advanced: Nested state machines for complex flows
type AuthStep = "credentials" | "verification" | "profile" | "complete";

type AuthState = {
  currentStep: AuthStep;
  userData: {
    email?: string;
    password?: string;
    verificationCode?: string;
    profile?: UserProfile;
  };
  errors: Partial<Record<AuthStep, string>>;
  isSubmitting: boolean;
};

type AuthAction =
  | { type: "SET_CREDENTIALS"; payload: { email: string; password: string } }
  | { type: "SET_VERIFICATION"; payload: { code: string } }
  | { type: "SET_PROFILE"; payload: { profile: UserProfile } }
  | { type: "NEXT_STEP" }
  | { type: "PREVIOUS_STEP" }
  | { type: "SET_ERROR"; payload: { step: AuthStep; error: string } }
  | { type: "CLEAR_ERROR"; payload: { step: AuthStep } }
  | { type: "SET_SUBMITTING"; payload: boolean }
  | { type: "RESET" };

function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case "SET_CREDENTIALS":
      return {
        ...state,
        userData: { ...state.userData, ...action.payload },
        errors: { ...state.errors, credentials: undefined },
      };

    case "SET_VERIFICATION":
      return {
        ...state,
        userData: { ...state.userData, verificationCode: action.payload.code },
        errors: { ...state.errors, verification: undefined },
      };

    case "SET_PROFILE":
      return {
        ...state,
        userData: { ...state.userData, profile: action.payload.profile },
        errors: { ...state.errors, profile: undefined },
      };

    case "NEXT_STEP":
      const steps: AuthStep[] = [
        "credentials",
        "verification",
        "profile",
        "complete",
      ];
      const currentIndex = steps.indexOf(state.currentStep);
      const nextStep = steps[Math.min(currentIndex + 1, steps.length - 1)];
      return { ...state, currentStep: nextStep };

    case "PREVIOUS_STEP":
      const stepsPrev: AuthStep[] = [
        "credentials",
        "verification",
        "profile",
        "complete",
      ];
      const currentIndexPrev = stepsPrev.indexOf(state.currentStep);
      const prevStep = stepsPrev[Math.max(currentIndexPrev - 1, 0)];
      return { ...state, currentStep: prevStep };

    case "SET_ERROR":
      return {
        ...state,
        errors: {
          ...state.errors,
          [action.payload.step]: action.payload.error,
        },
        isSubmitting: false,
      };

    case "CLEAR_ERROR":
      return {
        ...state,
        errors: { ...state.errors, [action.payload.step]: undefined },
      };

    case "SET_SUBMITTING":
      return { ...state, isSubmitting: action.payload };

    case "RESET":
      return {
        currentStep: "credentials",
        userData: {},
        errors: {},
        isSubmitting: false,
      };

    default:
      const exhaustiveCheck: never = action;
      return state;
  }
}

This state machine pattern eliminates impossible states (like being in a loading state with error data) and ensures all state transitions are explicit and trackable.

Best Practices for Production TypeScript

1. Start Strict, Stay Strict Enable all strict compiler options from day one. It's infinitely easier to write correct code than to retrofit loose code later.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true
  }
}

2. Embrace unknown Over any When you truly don't know a type, unknown forces you to narrow it safely before use, preventing runtime surprises.

// Bad: anything goes
function processApiResponse(data: any) {
  return data.user.name; // Runtime error if structure is different
}

// Good: forces type checking
function processApiResponse(data: unknown) {
  if (typeof data === "object" && data !== null && "user" in data) {
    const typedData = data as { user: { name: string } };
    return typedData.user.name;
  }
  throw new Error("Invalid API response structure");
}

3. Create Reusable Type Guards Type guards make your code more readable and reusable while providing runtime safety.

// Reusable type guards
function isString(value: unknown): value is string {
  return typeof value === "string";
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value
  );
}

function isArrayOf<T>(
  value: unknown,
  itemGuard: (item: unknown) => item is T
): value is T[] {
  return Array.isArray(value) && value.every(itemGuard);
}

// Usage
if (isArrayOf(apiResponse, isUser)) {
  // TypeScript now knows apiResponse is User[]
  apiResponse.forEach((user) => console.log(user.name));
}

4. Organize Types Strategically Keep types close to their usage, but create shared type libraries for domain concepts used across modules.

// types/user.ts - Shared domain types
export interface User {
  id: string;
  name: string;
  email: string;
}

// components/UserCard.tsx - Component-specific types
interface UserCardProps {
  user: User; // Imported from shared types
  onEdit?: (user: User) => void;
  showActions?: boolean;
}

5. Leverage ESLint with TypeScript The @typescript-eslint plugin catches issues that the compiler might miss and enforces consistent code style.

// .eslintrc.json
{
  "extends": [
    "@typescript-eslint/recommended",
    "@typescript-eslint/recommended-requiring-type-checking"
  ],
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/prefer-nullish-coalescing": "error",
    "@typescript-eslint/prefer-optional-chain": "error",
    "@typescript-eslint/no-unused-vars": "error"
  }
}

Conclusion: Beyond Types and Interfaces

TypeScript's real power isn't in basic type annotations—it's in these advanced patterns that turn your type system into a sophisticated reasoning engine. When you master discriminated unions, conditional types, branded types, and state machines, you're not just adding types to JavaScript; you're encoding business logic, preventing entire categories of bugs, and creating code that's both more reliable and more expressive.

The patterns in this guide represent years of collective experience from teams building production applications at scale. They've been battle-tested in environments where bugs cost money and downtime isn't an option.

Start incorporating these patterns gradually. Pick one that addresses a pain point in your current codebase, implement it, and experience the confidence that comes from having the compiler as your safety net. Once you've felt the power of impossible states being literally impossible to represent, you'll never want to go back to hoping your code works correctly.

TypeScript isn't just a tool—it's a mindset shift toward building software that's correct by construction rather than correct by testing. Master these patterns, and you'll join the ranks of developers who build systems that scale, maintain, and evolve with grace.