Featured image of post Branching in TS Type System: Intro to Conditional Types Featured image of post Branching in TS Type System: Intro to Conditional Types

Branching in TS Type System: Intro to Conditional Types

Create type logic that resolves output structures based on checks using Conditional Type constraints.

Introduction

TypeScript’s type system is Turing-complete, and one of the key features that unlocks its full power is conditional types. Just like ternary operators in JavaScript (condition ? a : b), conditional types let you create type-level logic: T extends U ? X : Y. This enables dynamic, context-aware type resolution.


Basic Syntax

A conditional type checks whether a type extends another type:

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

type A = IsString<'hello'>; // true
type B = IsString<42>;      // false

The condition T extends U asks: “Is T assignable to U?” If yes, resolve to the true branch; otherwise, the false branch.


The infer Keyword

The infer keyword allows you to extract a type from within a condition:

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

function greet() { return 'hello'; }
type G = ReturnType<typeof greet>; // string

infer R declares a type variable R that is inferred from the matched position. This is the foundation of many utility types.


Distributive Conditional Types

When a conditional type is used with a union, it distributes over each member:

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

type Result = ToArray<string | number>;
// string[] | number[]  (not (string | number)[])

This distribution happens automatically unless you wrap both sides:

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

type Result2 = ToArrayNonDist<string | number>;
// (string | number)[]

Built-in Utility Types Using Conditionals

Many standard utility types rely on conditional types:

// Exclude — removes types from a union
type T0 = Exclude<'a' | 'b' | 'c', 'a'>;
// 'b' | 'c'

// Extract — keeps only matching types
type T1 = Extract<string | number | (() => void), Function>;
// () => void

// NonNullable — removes null and undefined
type T2 = NonNullable<string | number | null | undefined>;
// string | number

Here’s how Exclude is implemented:

type MyExclude<T, U> = T extends U ? never : T;

Real-World Patterns

Conditional Return Types Based on Input

type AsyncResult<T> = T extends Promise<infer U> ? U : T;

async function fetchData(): Promise<string> {
  return 'data';
}

type Data = AsyncResult<ReturnType<typeof fetchData>>; // string

Function Overloads Without Overloads

type StringOrNumber<T> = T extends string ? string : number;

function process<T extends string | number>(val: T): StringOrNumber<T>;
function process(val: any): any {
  return typeof val === 'string' ? val.toUpperCase() : val * 10;
}

const a = process('hello'); // string
const b = process(42);      // number

Deep Conditional Type Example

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? DeepReadonly<T[K]>
    : T[K];
};

interface User {
  name: string;
  address: { city: string; zip: number };
}

type ReadonlyUser = DeepReadonly<User>;
// { readonly name: string; readonly address: { readonly city: string; readonly zip: number } }

Conclusion

Conditional types are the if/else of the type system. Combined with infer, distribution, and mapped types, they enable sophisticated type transformations that catch bugs at compile time. Master conditionals to write truly type-safe, self-documenting TypeScript code.