Advanced Conditional Types

Advanced TypeScript — Advanced Conditional Types
Advanced TypeScript
Course 4 · Chapter 1 · Advanced Conditional Types

🌲 Advanced Conditional Types

Course 2 introduced conditional types as a ternary at the type level. This chapter goes further into how the type checker actually evaluates them: chained conditions, the surprising "distributive" behavior over unions, deeper infer patterns, and how to build genuinely reusable type guards on top of all of it.

Quick Recap

A conditional type picks between two branches based on an extends check, evaluated entirely at compile time:

type IsString<T> = T extends string ? true : false; type A = IsString<"hi">; // true type B = IsString<42>; // false

Nested Conditional Types

Conditional types chain the same way if/else if/else does — each : can itself be another conditional:

type TypeName<T> = T extends string ? "string" : T extends number ? "number" : T extends boolean ? "boolean" : T extends undefined ? "undefined" : T extends Function ? "function" : "object"; type A = TypeName<"hi">; // "string" type B = TypeName<() => void>; // "function" type C = TypeName<{ x: 1 }>; // "object"

This is exactly how TypeScript's own built-in Parameters<T>, ReturnType<T>, and similar utility types are implemented under the hood — a chain of conditions, each narrowing what T could be.

🔀 Distributive Conditional Types

The single most surprising rule in this chapter: a conditional type distributes over a union when the checked type is a "naked" type parameter — it runs the check separately for each union member and unions the results back together:

type ToArray<T> = T extends any ? T[] : never; type Result = ToArray<string | number>; // NOT (string | number)[] — instead: string[] | number[] // TypeScript ran ToArray<string> and ToArray<number> separately, then unioned the results.

Distributive vs Non-Distributive

Distributive (naked type parameter)
type Filter<T> = T extends string ? T : never; type R = Filter<string | number | boolean>; // R = string — number and boolean each // individually failed the check and became `never`, // and `never` disappears from a union automatically.
Non-Distributive (wrapped in a tuple)
type FilterWhole<T> = [T] extends [string] ? T : never; type R = FilterWhole<string | number | boolean>; // R = never — the WHOLE union is checked against // `string` at once (it isn't one), so nothing survives.

This is exactly how Exclude<T, U> is implemented — it relies on distribution to filter a union member-by-member:

type Exclude<T, U> = T extends U ? never : T; // Exclude<"a" | "b" | "c", "a"> runs per member: // "a" extends "a" ? never : "a" -> never // "b" extends "a" ? never : "b" -> "b" // "c" extends "a" ? never : "c" -> "c" // Result: "b" | "c"

🔍 Deeper Inference Patterns

infer can appear more than once, and in more places than a simple function return type:

// Extract the element type from an array type ElementType<T> = T extends (infer E)[] ? E : T; type A = ElementType<string[]>; // string // Extract a Promise's resolved value, unwrapping nested Promises type Awaited2<T> = T extends Promise<infer U> ? Awaited2<U> : T; type B = Awaited2<Promise<Promise<number>>>; // number // Extract a function's parameter types as a tuple type Params<T> = T extends (...args: infer A) => any ? A : never; type C = Params<(a: string, b: number) => void>; // [a: string, b: number]

Recursive Conditional Types

Awaited2 calls itself in its own true-branch — the type checker recurses just like a function would, unwrapping nested Promise<Promise<T>> layers.

infer in a Tuple Position

infer A in (...args: infer A) captures the entire parameter list as one tuple type, not each parameter separately.

Complex Type Guards Built From Conditionals

Some checks that feel like they should be simple turn out to need real conditional-type trickery — the classic example is detecting any, which behaves unlike every other type:

IsAny<T>: Detecting the One Type That Breaks the Rules

// A naive check doesn't work: `any extends string ? true : false` // resolves to `boolean` (both branches at once) because `any` matches everything. // The working trick: compare two DIFFERENT conditional branches against each other. type IsAny<T> = 0 extends (1 & T) ? true : false; type A = IsAny<any>; // true — (1 & any) collapses to `any`, and 0 extends any type B = IsAny<string>; // false — (1 & string) is `never`, and 0 does not extend never type C = IsAny<unknown>; // false — behaves correctly, unlike the naive version

This is an intentionally deep-cut example — most real code will never need IsAny<T> directly, but understanding why it works is a good test of whether the distributivity and inference rules above actually clicked.

💻 Coding Challenges

Challenge 1: Build a Nested Conditional

Write a Flatten<T> type that: returns T's array element type if T is an array, returns T's Promise-resolved type if T is a Promise, and returns T unchanged otherwise.

Goal: Practice chaining multiple extends checks in sequence, each with its own infer.

→ Solution

Challenge 2: Exploit Distribution

Write a type NonFunctionKeys<T> that, given an object type, produces a union of only the keys whose values are not functions.

Goal: Practice combining a mapped type with a distributive conditional to filter keys based on their value's type.

→ Solution

Challenge 3: Write DeepAwaited

Write a recursive conditional type DeepAwaited<T> that fully unwraps any level of nested Promise<Promise<...Promise<T>...>> down to the innermost non-Promise type.

Goal: Practice a conditional type that recurses on itself until a base case is reached.

→ Solution

⚠️ Gotcha: Accidental Distribution

Distribution happens automatically whenever the checked type is a bare, unwrapped type parameter — which means it can happen when you didn't want it, silently turning MyType<A | B> into Result<A> | Result<B> instead of one combined result. If a conditional type is behaving strangely on a union input, wrap both sides in a single-element tuple ([T] extends [U]) to force a non-distributive, whole-union comparison, then decide deliberately whether distribution is actually what you want.

🎯 What's Next

Conditional types can check a shape — the next chapter tackles shapes that reference themselves: Recursive Types & Tree Structures, representing trees and graphs at the type level, with depth-aware constraints to keep the compiler from recursing forever.