TypeScript Conditional Types — Type-Level Logic for Backend Engineers

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Why This Matters

Conditional types take TypeScript from a type annotation system to a type computation system. They allow you to write type-level logic that chooses between types based on the input — exactly like a runtime conditional, but evaluated entirely at compile time.

Backend engineers encounter conditional types frequently in framework code, utility libraries, and complex service layers. Every built-in utility like ReturnType, Awaited, NonNullable, and Extract is implemented with conditional types. When you understand how they work, you can build your own sophisticated utilities and understand existing ones deeply.

Conditional types combined with infer are especially powerful — they let you extract parts of complex types (unwrap Promises, extract function parameters, pull union members) without any runtime code.

Basic Syntax

The syntax is T extends U ? X : Y. If T is assignable to U, the result is X, otherwise Y.

type IsString<T> = T extends string ? true : false;
 
type A = IsString<'hello'>;   // true
type B = IsString<42>;        // false
type C = IsString<string>;    // true
 
// Nested conditionals
type TypeName<T> =
  T extends string ? 'string' :
  T extends number ? 'number' :
  T extends boolean ? 'boolean' :
  T extends null ? 'null' :
  T extends undefined ? 'undefined' :
  'object';
 
type T1 = TypeName<string>;    // 'string'
type T2 = TypeName<42>;        // 'number'
type T3 = TypeName<null>;      // 'null'
type T4 = TypeName<string[]>;  // 'object'

The infer Keyword

infer extracts type information from within a conditional. It declares a new type variable inside the extends clause.

// Extract the return type of a function
type MyReturnType<T extends (...args: any[]) => any> =
  T extends (...args: any[]) => infer R ? R : never;
 
function fetchUser(id: string): Promise<{ id: string; name: string }> {
  return Promise.resolve({ id, name: 'Alice' });
}
 
type UserResponse = MyReturnType<typeof fetchUser>;
// Promise<{ id: string; name: string }>
 
// Unwrap a Promise (equivalent to built-in Awaited<T>)
type Unwrap<T> = T extends Promise<infer R> ? Unwrap<R> : T;
type UserData = Unwrap<Promise<Promise<{ id: string }>>>;
// { id: string }
 
// Extract array element type
type ElementOf<T> = T extends (infer E)[] ? E : never;
type StrElem = ElementOf<string[]>;    // string
type NumElem = ElementOf<number[][]>;  // number[]
 
// Extract function parameters as a tuple
type Params<T extends (...args: any[]) => any> =
  T extends (...args: infer P) => any ? P : never;
 
type CreateUserParams = Params<(name: string, email: string, age: number) => void>;
// [string, string, number]

Distributive Conditional Types

When the input type is a naked type parameter (not wrapped in a tuple or object), conditional types distribute over union members automatically.

type ToArray<T> = T extends any ? T[] : never;
 
// Distributes over the union:
type StrOrNumArr = ToArray<string | number>;
// string[] | number[]
 
// vs. without distribution (wrap in tuple):
type ToArrayFixed<T> = [T] extends [any] ? T[] : never;
type StrOrNumArrFixed = ToArrayFixed<string | number>;
// (string | number)[]
 
// Practical: implement Exclude<T, U>
type MyExclude<T, U> = T extends U ? never : T;
type NoString = MyExclude<string | number | boolean, string>;
// number | boolean
 
// Practical: implement Extract<T, U>
type MyExtract<T, U> = T extends U ? T : never;
type OnlyString = MyExtract<string | number | boolean, string>;
// string

Real-World: API Response Types

type HttpStatus = 200 | 201 | 400 | 401 | 403 | 404 | 500;
 
type ApiResponse<T, S extends HttpStatus> =
  S extends 200 | 201
    ? { status: S; data: T; timestamp: string }
    : S extends 400
      ? { status: 400; errors: Array<{ field: string; message: string }> }
      : S extends 401 | 403
        ? { status: S; message: 'Unauthorized' | 'Forbidden' }
        : S extends 404
          ? { status: 404; message: string; resource: string }
          : { status: 500; message: string; requestId: string };
 
type SuccessResponse = ApiResponse<{ id: string }, 200>;
// { status: 200; data: { id: string }; timestamp: string }
 
type BadRequest = ApiResponse<never, 400>;
// { status: 400; errors: Array<{ field: string; message: string }> }
 
type NotFound = ApiResponse<never, 404>;
// { status: 404; message: string; resource: string }

Recursive Conditional Types

// Deep flatten array type
type DeepFlat<T> = T extends (infer U)[] ? DeepFlat<U> : T;
 
type Nested = string[][][];
type Flat = DeepFlat<Nested>;  // string
 
// Recursive promise unwrapping
type DeepAwaited<T> = T extends Promise<infer R> ? DeepAwaited<R> : T;
 
// Get all keys of deeply nested object (dot-notation paths)
type DotPaths<T, Prefix extends string = ''> = {
  [K in keyof T & string]: T[K] extends object
    ? DotPaths<T[K], `${Prefix}${Prefix extends '' ? '' : '.'}${K}`> | `${Prefix}${Prefix extends '' ? '' : '.'}${K}`
    : `${Prefix}${Prefix extends '' ? '' : '.'}${K}`;
}[keyof T & string];
 
interface Config {
  server: { host: string; port: number };
  db: { url: string };
}
 
type ConfigPaths = DotPaths<Config>;
// 'server' | 'server.host' | 'server.port' | 'db' | 'db.url'

Type-Level Builder Pattern

// Track which required fields have been set at the type level
type RequiredFields<T> = {
  [K in keyof T]-?: undefined extends T[K] ? never : K;
}[keyof T];
 
type OptionalFields<T> = {
  [K in keyof T]-?: undefined extends T[K] ? K : never;
}[keyof T];
 
interface RequestConfig {
  url: string;
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  timeout?: number;
  headers?: Record<string, string>;
}
 
type ConfigRequired = RequiredFields<RequestConfig>;  // 'url' | 'method'
type ConfigOptional = OptionalFields<RequestConfig>;  // 'timeout' | 'headers'
 
// Check if all required fields are set
type IsComplete<T, Set extends keyof T> =
  RequiredFields<T> extends Set ? true : false;

Conditional Return Types

// Function overloads via conditional return types
function parse<T extends boolean>(
  value: string,
  strict: T
): T extends true ? number : number | null {
  const n = parseFloat(value);
  if (strict && isNaN(n)) throw new Error(`Cannot parse "${value}" as number`);
  return (isNaN(n) ? null : n) as T extends true ? number : number | null;
}
 
const strict = parse('42', true);    // number
const loose = parse('abc', false);   // number | null

Common Mistakes

  • Forgetting that naked type parameters distribute over unions — wrap in [T] to prevent this
  • Using extends inside conditional types incorrectly — it tests assignability, not equality
  • Writing deeply recursive conditional types that cause TypeScript compiler timeouts
  • Mixing infer with multiple constraints — only the last infer binding in an extends clause is used
  • Returning never instead of a fallback type when the condition is false, causing silent type erasure

Best Practices

  • Use built-in conditional utilities (Awaited, ReturnType, Parameters) before writing custom ones
  • Test conditional types with several representative inputs using type aliases and IDE hover
  • Prefer simpler mapped types for property transformations — use conditionals when you need type selection
  • Document the expected input/output of complex conditional types with examples in comments
  • Keep recursive conditional types bounded — add a depth parameter when nesting more than 2 levels

Key Takeaways

  • Conditional types follow the syntax T extends U ? X : Y — type-level if/else
  • infer inside a conditional type extracts and names a type for use in the result branch
  • Distributive conditional types automatically apply to each member of a union type
  • Wrapping in a tuple ([T] extends [U]) disables distributive behavior for union types
  • Awaited<T>, ReturnType<F>, Parameters<F>, Extract<T,U>, and Exclude<T,U> are built with conditional types
  • Recursive conditional types can unwrap nested Promises, flatten nested arrays, and traverse object paths
  • Conditional types are evaluated entirely at compile time — zero runtime overhead
  • Combining infer with template literal types enables parsing string type patterns at compile time

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading