TypeScript Conditional Types — Dynamic Types

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Conditional types are TypeScript's type-level conditionals. They enable dynamic type relationships and are the foundation of advanced type manipulation. Understanding them unlocks metaprogramming capabilities.

Basic Conditional Types

The syntax is T extends U ? X : Y — if T is assignable to U, the type is X, otherwise Y:

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

type A = IsString<"hello">; // true
type B = IsString<number>; // false
type C = IsString<"hello" | 42>; // true | false (distributed)

// Practical example
type Flatten<T> = T extends Array<infer U> ? U : T;

type FlatString = Flatten<string[]>; // string
type FlatNum = Flatten<number>; // number

Type Inference with infer

Extract types from complex structures:

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

function getString(): string {
  return "hello";
}

type StringReturn = ReturnType<typeof getString>; // string

// Extract array element
type Unwrap<T> = T extends (infer U)[] ? U : T;

type UnwrapString = Unwrap<string[]>; // string
type UnwrapNum = Unwrap<number>; // number (not an array)

// Extract from promise
type Awaited<T> = T extends Promise<infer U> ? U : T;

type ResolvedUser = Awaited<Promise<User>>; // User
type PlainValue = Awaited<string>; // string

Conditional Type Distribution

Conditional types distribute over union types:

// Without union handling
type ToArray<T> = T[];
type Result = ToArray<string | number>; // (string | number)[]

// With conditional distribution
type ToArrayConditional<T> = T extends any ? T[] : never;
type Result2 = ToArrayConditional<string | number>; // string[] | number[]

// Disable distribution with tuple
type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;
type Result3 = ToArrayNoDistribute<string | number>; // (string | number)[]

// Practical: filter union types
type Exclude<T, U> = T extends U ? never : T;

type NonString = Exclude<string | number | boolean, string>; // number | boolean
type NoNull = Exclude<string | null | undefined, null | undefined>; // string

Complex Type Relationships

// Check if type is function
type IsFunction<T> = T extends Function ? true : false;

type A = IsFunction<() => void>; // true
type B = IsFunction<string>; // false

// Get function parameters
type GetParams<T extends (...args: any) => any> = T extends (
  ...args: infer P
) => any
  ? P
  : never;

function add(a: number, b: number): number {
  return a + b;
}

type AddParams = GetParams<typeof add>; // [number, number]

// Extract object value types
type ValueOf<T extends object> = T[keyof T];

interface Config {
  timeout: number;
  retries: number;
  maxSize: number;
}

type ConfigValues = ValueOf<Config>; // number

Recursive Conditional Types

// Get deeply nested array element type
type DeepFlat<T> = T extends (infer U)[]
  ? DeepFlat<U>
  : T;

type NestedArray = [[[1, 2]], [3, 4]];
type Flattened = DeepFlat<NestedArray>; // 1 | 2 | 3 | 4

// Get all properties and nested properties
type AllKeys<T> = T extends object
  ? {
      [K in keyof T]: K | AllKeys<T[K]>;
    }[keyof T]
  : never;

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

type UserKeys = AllKeys<User>;
// "id" | "profile" | "name" | "address" | "city"

Advanced Patterns

Mapped Types with Conditionals

// Create properties with different types
interface Timestamps {
  createdAt: Date;
  updatedAt: Date;
}

type ToTimestampStrings<T extends Record<string, Date>> = {
  [K in keyof T]: T[K] extends Date ? string : T[K];
};

type StringTimestamps = ToTimestampStrings<Timestamps>;
// {
//   createdAt: string;
//   updatedAt: string;
// }

// Split properties by type
type SplitByType<T, U> = {
  matching: {
    [K in keyof T as T[K] extends U ? K : never]: T[K];
  };
  rest: {
    [K in keyof T as T[K] extends U ? never : K]: T[K];
  };
};

interface Mixed {
  name: string;
  age: number;
  email: string;
  active: boolean;
}

type Split = SplitByType<Mixed, string>;
// {
//   matching: { name: string; email: string };
//   rest: { age: number; active: boolean };
// }

Builder Pattern with Conditionals

// Track which properties are set
type Builder<T, Set extends keyof T = never> = {
  set<K extends keyof T>(
    key: K,
    value: T[K]
  ): Builder<T, Set | K>;
  build(): Set extends keyof T ? T : never;
};

class TypedBuilder<T, Set extends keyof T = never> implements Builder<T, Set> {
  private data: Partial<T> = {};

  set<K extends keyof T>(key: K, value: T[K]): Builder<T, Set | K> {
    this.data[key] = value;
    return this as any;
  }

  build(): any {
    return this.data;
  }
}

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

const builder = new TypedBuilder<User>();
// builder.set("name", "Alice").build(); // Error: missing required properties
// builder.set("id", 1).set("name", "Alice").set("email", "alice@example.com").build(); // OK

API Response Handler

type ApiResponse =
  | { status: 200; data: any }
  | { status: 404; error: string }
  | { status: 500; error: string };

// Extract response type for a specific status
type ResponseFor<S extends number> = ApiResponse extends { status: S }
  ? ApiResponse & { status: S }
  : never;

type SuccessResponse = ResponseFor<200>; // { status: 200; data: any }
type NotFoundResponse = ResponseFor<404>; // { status: 404; error: string }
type ErrorResponse = ResponseFor<500>; // { status: 500; error: string }

// Handle response by status code
function handleResponse(response: ApiResponse): string {
  if (response.status === 200) {
    return JSON.stringify(response.data);
  } else if (response.status === 404 || response.status === 500) {
    return `Error: ${response.error}`;
  }
  return "Unknown response";
}

Overload Resolution

// Create overloads from conditional types
type FunctionOverloads<T> = T extends { async: true }
  ? (...args: any[]) => Promise<unknown>
  : (...args: any[]) => unknown;

interface Config {
  async: true;
}

type AsyncFn = FunctionOverloads<Config>; // (...args) => Promise<unknown>

// Multiple dispatch based on type
type Dispatch<T> = T extends "string"
  ? (value: string) => void
  : T extends "number"
    ? (value: number) => void
    : T extends "boolean"
      ? (value: boolean) => void
      : never;

type DispatchString = Dispatch<"string">; // (value: string) => void
type DispatchNum = Dispatch<"number">; // (value: number) => void

Practical Example: Type-Safe Query Builder

interface QueryOperations {
  where<K extends string>(
    field: K,
    operator: "=" | ">" | "<" | "like",
    value: any
  ): QueryBuilder;
  select<K extends string>(...fields: K[]): QueryBuilder;
  limit(n: number): QueryBuilder;
  build(): string;
}

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

type ValidFieldType<T, Field> = Field extends keyof T
  ? T[Field]
  : "Invalid field";

class QueryBuilder implements QueryOperations {
  where<K extends string>(
    field: K,
    operator: string,
    value: any
  ): QueryBuilder {
    // Implementation
    return this;
  }

  select<K extends string>(...fields: K[]): QueryBuilder {
    // Implementation
    return this;
  }

  limit(n: number): QueryBuilder {
    // Implementation
    return this;
  }

  build(): string {
    return "SELECT * FROM table";
  }
}

Performance Considerations

  1. Avoid deeply recursive conditionals - they slow compilation
  2. Use union distribution carefully - it can create exponentially large types
  3. Prefer simpler alternatives - mapped types often work better

FAQ

Q: What's the difference between conditional types and function overloads? A: Conditional types operate at the type level. Overloads operate at the implementation level. Use conditionals for type transformation, overloads for multiple implementations.

Q: Can conditional types cause infinite recursion? A: Yes, if you're not careful with recursive types. TypeScript has protection, but complex recursion can cause issues. Test carefully.

Q: When should I use conditional types? A: When you need dynamic type behavior based on input types. For simple transformations, mapped types are often clearer. Conditional types shine in frameworks and libraries.


Conditional types are where TypeScript becomes a true metaprogramming language. They enable patterns impossible in other type systems. Master them and you can build frameworks and libraries that provide unmatched type safety and developer experience.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro