TypeScript
32 min read 8,750 views

TypeScript Advanced Patterns: From Good to Great

“Level up your TypeScript skills with these advanced patterns used in production applications at scale.”

C

CodEarn Tech Engineering

Engineering Team, CodEarn Tech

Published

January 8, 2026

TypeScript Advanced Patterns: From Good to Great

Mastering TypeScript's Type System

TypeScript's type system is Turing complete, meaning it can express any computation that a Turing machine can perform. While you rarely need that theoretical power, understanding advanced type patterns transforms how you write and architect TypeScript applications. This guide explores patterns used by teams at Google, Microsoft, and Stripe to build type-safe applications at scale.

We'll move beyond basic generics and interfaces into the sophisticated techniques that make impossible states unrepresentable, enforce business rules at compile time, and provide IDE experiences that feel magical. These patterns require investment to learn but pay dividends in reduced runtime errors and faster refactoring.

Conditional Types Deep Dive

Conditional types are the if-statements of TypeScript's type system. They enable type-level logic that adapts based on input types. Mastering them is essential for advanced TypeScript development.

Basic Syntax and Usage:


      type IsString = T extends string ? true : false;

      type A = IsString<"hello">; // true
      type B = IsString<123>;     // false
      

The extends keyword checks if a type is assignable to another. The conditional distributes over unions, making it powerful for mapping types:


      type ToArray = T extends any ? T[] : never;
      type StringsOrNumbers = ToArray; // string[] | number[]
      

Infer Keyword: The infer keyword extracts types from complex structures. It's essential for unpacking function return types, promise resolutions, and array elements:


      type ReturnType = T extends (...args: any[]) => infer R ? R : never;
      type PromiseType = T extends Promise ? P : never;

      async function fetchUser() { return { id: 1, name: "Alice" }; }
      type User = PromiseType>; // { id: number; name: string; }
      

Real-World Pattern: API Response Normalization:


      type ApiResponse =
        | { status: 'success'; data: T }
        | { status: 'error'; error: string };

      type ExtractData = T extends ApiResponse ? D : never;

      function createApiClient() {
        return async (url: string): Promise>> => {
          const response = await fetch(url);
          const result: ApiResponse = await response.json();
          if (result.status === 'error') throw new Error(result.error);
          return result.data;
        };
      }
      

Template Literal Types and String Manipulation

TypeScript 4.1 introduced template literal types, enabling type-safe string manipulation. This feature is transformative for routing, event handling, and CSS-in-JS libraries.

Basic Template Literals:


      type EventName = `on${Capitalize}`;
      type ClickEvent = EventName<'click'>; // 'onClick'
      type HoverEvent = EventName<'hover'>; // 'onHover'
      

Type-Safe Routing: Build routers where paths and parameters are fully typed:


      type RouteParams =
        T extends `${infer Start}/:${infer Param}/${infer Rest}`
          ? { [K in Param]: string } & RouteParams<`${Start}/${Rest}`>
          : T extends `${string}/:${infer Param}`
          ? { [K in Param]: string }
          : {};

      type UserRoute = RouteParams<'/users/:id/posts/:postId'>;
      // Result: { id: string; postId: string }
      

CSS-in-JS Type Safety: Enforce design system constraints at the type level:


      type Spacing = 'sm' | 'md' | 'lg' | 'xl';
      type SpacingToken = `space-${Spacing}`;

      type Colors = 'primary' | 'secondary' | 'danger';
      type ColorToken = `color-${Colors}`;

      type DesignToken = SpacingToken | ColorToken;

      const styles: Record = {
        'space-sm': '0.25rem',
        'color-primary': '#007bff',
      };
      

Recursive Types and Tree Structures

Recursive types describe self-referential data structures like trees, nested menus, and JSON. They're essential for working with hierarchical data.

JSON Type Definition: A complete type for any valid JSON:


      type JSONValue =
        | string
        | number
        | boolean
        | null
        | JSONValue[]
        | { [key: string]: JSONValue };

      function parseJSON(input: string): JSONValue {
        return JSON.parse(input);
      }
      

Nested Menu Structure:


      interface MenuItem {
        label: string;
        icon?: string;
        action?: () => void;
        children?: MenuItem[];
      }

      const Menu: React.FC<{ items: MenuItem[] }> = ({ items }) => (
        
    {items.map(item => (
  • {item.label} {item.children && }
  • ))}
);

Deep Partial and Deep Required: Utility types that recurse into nested objects:


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

      type DeepRequired = {
        [P in keyof T]-?: T[P] extends object ? DeepRequired : T[P];
      };
      

Discriminated Unions and Exhaustiveness Checking

Discriminated unions are TypeScript's most powerful feature for modeling state. Combined with exhaustiveness checking, they eliminate entire classes of runtime errors.

State Machine Pattern: Model UI states precisely:


      type AsyncState =
        | { status: 'idle' }
        | { status: 'loading'; progress: number }
        | { status: 'success'; data: T }
        | { status: 'error'; error: Error };

      function handleState(state: AsyncState): string {
        switch (state.status) {
          case 'idle': return 'Waiting to start...';
          case 'loading': return `Loading: ${state.progress}%`;
          case 'success': return `Loaded: ${state.data}`;
          case 'error': return `Error: ${state.error.message}`;
          default:
            const _exhaustive: never = state;
            return _exhaustive;
        }
      }
      

The never assignment in the default case ensures that if you add a new status to AsyncState, TypeScript will error until you handle it. This makes impossible states unrepresentable and ensures all cases are handled.

Redux-Style Actions:


      type UserAction =
        | { type: 'USER_LOGIN'; payload: { userId: string; token: string } }
        | { type: 'USER_LOGOUT' }
        | { type: 'USER_UPDATE_PROFILE'; payload: { name: string; email: string } };

      function userReducer(state: UserState, action: UserAction): UserState {
        switch (action.type) {
          case 'USER_LOGIN':
            return { ...state, isAuthenticated: true, userId: action.payload.userId };
          case 'USER_LOGOUT':
            return { ...state, isAuthenticated: false, userId: null };
          case 'USER_UPDATE_PROFILE':
            return { ...state, profile: action.payload };
          default:
            return assertNever(action);
        }
      }

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

Type Guards and Custom Narrowing

Type guards are functions that narrow types based on runtime checks. Custom type guards enable sophisticated validation logic with type safety.

Built-in Type Guards:


      function isString(value: unknown): value is string {
        return typeof value === 'string';
      }

      function isDate(value: unknown): value is Date {
        return value instanceof Date;
      }

      function isArray(value: unknown): value is T[] {
        return Array.isArray(value);
      }
      

Custom Validation Guards:


      interface User {
        id: number;
        name: string;
        email: string;
        role: 'admin' | 'user';
      }

      function isUser(value: unknown): value is User {
        return (
          typeof value === 'object' &&
          value !== null &&
          'id' in value &&
          'name' in value &&
          'email' in value &&
          'role' in value &&
          typeof (value as User).id === 'number' &&
          typeof (value as User).name === 'string' &&
          ['admin', 'user'].includes((value as User).role)
        );
      }

      async function fetchUser(id: number): Promise {
        const response = await fetch(`/api/users/${id}`);
        const data = await response.json();

        if (!isUser(data)) {
          throw new Error('Invalid user data received from API');
        }

        return data;
      }
      

Branded Types for Type-Safe IDs: Prevent mixing up different ID types:


      type Brand = K & { __brand: T };

      type UserId = Brand;
      type OrderId = Brand;

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

      function createOrderId(id: number): OrderId {
        return id as OrderId;
      }

      const userId = createUserId(1);
      const orderId = createOrderId(1);

      function getUser(id: UserId) { /* ... */ }
      getUser(userId); // OK
      // getUser(orderId); // Error
      

Mapped Types and Key Remapping

Mapped types create new types by transforming properties of existing types. TypeScript 4.1 added key remapping, making them even more powerful.

Basic Mapped Types:


      type Partial = { [P in keyof T]?: T[P] };
      type Required = { [P in keyof T]-?: T[P] };
      type Readonly = { readonly [P in keyof T]: T[P] };
      type Mutable = { -readonly [P in keyof T]: T[P] };
      type Pick = { [P in K]: T[P] };
      type Omit = Pick>;
      

Key Remapping with as:


      type CamelCase = S extends `${infer P}_${infer Q}`
        ? `${P}${Capitalize>}`
        : S;

      type Camelize = {
        [K in keyof T as CamelCase]: T[K] extends object
          ? Camelize
          : T[K]
      };

      interface Snake_Case {
        user_name: string;
        email_address: string;
        contact_info: { phone_number: string };
      }

      type CamelCaseType = Camelize;
      // { userName: string; emailAddress: string; contactInfo: { phoneNumber: string } }
      

Event Payload Mapping: Create type-safe event emitters:


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

      type EventPayload = EventMap[T];

      class TypedEventEmitter {
        emit(event: T, payload: EventPayload) {}
        on(event: T, handler: (payload: EventPayload) => void) {}
      }

      const emitter = new TypedEventEmitter();
      emitter.emit('user:login', { userId: '123', timestamp: Date.now() }); // OK
      

Variance and Type Relationships

Understanding variance — how type relationships work with generics — is crucial for designing robust APIs.

The Variance Problem:


      interface Animal { name: string; }
      interface Dog extends Animal { breed: string; }

      const dogs: Dog[] = [{ name: 'Rex', breed: 'German Shepherd' }];
      const animals: Animal[] = dogs; // OK - covariant

      type AnimalHandler = (animal: Animal) => void;
      type DogHandler = (dog: Dog) => void;

      const handleAnimal: AnimalHandler = (animal) => console.log(animal.name);
      const handleDog: DogHandler = handleAnimal; // Error - contravariant
      

Performance and Compilation Optimization

Complex types can slow down compilation. These patterns help maintain type safety without sacrificing build performance.

Avoid Deep Nesting: TypeScript has a recursion depth limit (around 50 levels). Flatten types when possible:


      type Nested = { a: { b: { c: { d: string } } } };
      type Flattened = { 'a.b.c.d': string };
      

Use Interfaces over Type Aliases for Objects: Interfaces are generally faster to compile and provide better error messages:


      interface User {
        name: string;
        email: string;
      }
      

Conditional Type Distribution: Be aware that conditional types distribute over unions. Sometimes you need to prevent this:


      type ToArray = T extends any ? T[] : never;
      type ToArrayNonDist = [T] extends [any] ? T[] : never;
      

Real-World Architecture Patterns

Putting it all together, here are architectural patterns used in production systems.

Type-Safe Dependency Injection:


      interface ServiceMap {
        logger: Logger;
        database: Database;
        cache: Cache;
        emailService: EmailService;
      }

      type ServiceKey = keyof ServiceMap;

      class Container {
        private services = new Map();

        register(key: K, service: ServiceMap[K]) {
          this.services.set(key, service);
        }

        resolve(key: K): ServiceMap[K] {
          const service = this.services.get(key);
          if (!service) throw new Error(`Service ${key} not registered`);
          return service as ServiceMap[K];
        }
      }
      

Builder Pattern with Method Chaining:


      class QueryBuilder {
        private conditions: string[] = [];
        private params: unknown[] = [];

        where(field: K, operator: '=', value: T[K]): this {
          this.conditions.push(`${String(field)} = ?`);
          this.params.push(value);
          return this;
        }

        orderBy(field: K, direction: 'ASC' | 'DESC' = 'ASC'): this {
          return this;
        }

        build(): { sql: string; params: unknown[] } {
          return {
            sql: `SELECT * FROM table WHERE ${this.conditions.join(' AND ')}`,
            params: this.params,
          };
        }
      }

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

      const query = new QueryBuilder()
        .where('name', '=', 'Alice')
        .where('age', '=', 30)
        .orderBy('id', 'DESC')
        .build();
      

Conclusion

Advanced TypeScript patterns transform your codebase from error-prone JavaScript with types to a robust system where the compiler verifies your logic. The investment in learning these patterns pays off through fewer production bugs, faster refactoring, and superior developer experience.

Start by implementing discriminated unions for your application state. Add template literal types for your routing and theming systems. Use branded types to prevent ID confusion. Gradually adopt more complex patterns as your comfort grows.

Remember that types are a means to an end — reliable, maintainable software. Don't pursue type sophistication for its own sake. The best TypeScript code is code that other developers can understand and modify confidently. Use these patterns to make impossible states unrepresentable, enforce business rules at compile time, and create APIs that guide users toward correct usage.

Enjoyed this article?

If you found this helpful, consider sharing it with your network. It helps us grow.

Read Next