Skip to main content

Conditional types in TypeScript

Conditional types in TypeScript select one of two types based on whether T is assignable to U, using T extends U ? X : Y syntax that runs entirely at compile time.

Theory

TL;DR

  • Syntax: T extends U ? X : Y. T fits U, you get X. Otherwise you get Y.
  • Think vending machine: insert a type, the machine checks if it matches, dispenses one result.
  • Main difference from unions: conditional types distribute over each union member separately when T is a bare generic.
  • infer captures a sub-type during the check: T extends Promise<infer U> ? U : T.
  • Built on this: ReturnType, Exclude, Extract, NonNullable, Parameters.

Quick example

typescript
type IsString<T> = T extends string ? "yes" : "no"; type A = IsString<string>; // "yes" type B = IsString<number>; // "no" type C = IsString<"hi">; // "yes" -- literal "hi" is a subtype of string

T extends string tests structural assignability, not identity. String literals pass because they are subtypes of string.

Key difference from unions

A union like string | number lists possibilities. A conditional type runs a check and produces one specific result. That is why ReturnType<T> works but no union can replicate it: you need to test T extends (...args: any[]) => infer R to extract R. Unions have no mechanism for that.

When to use

  • Extract a function's return type or parameter types -> use infer.
  • Filter union members by removing matches -> T extends SomeType ? never : T (that is Exclude exactly).
  • Apply a transformation only to object types -> T extends object ? DeepPartial<T> : T.
  • Build a utility type that branches on shape instead of writing 3-4 manual overloads.

If a plain union or intersection does the job, skip the conditional. Not every type problem needs a ternary.

Distribution over unions

When you pass a union to a conditional type with a bare generic T, TypeScript splits the union and runs the condition per member:

typescript
type Wrap<T> = T extends any ? [T] : never; type A = Wrap<string | number>; // Runs as: Wrap<string> | Wrap<number> // Result: [string] | [number]

If T is wrapped in brackets, an array, or another generic, distribution stops:

typescript
type WrapFixed<T> = [T] extends [any] ? T[] : never; type B = WrapFixed<string | number>; // Result: (string | number)[] -- treated as one type

Wrapping in [T] is the standard way to opt out of distribution.

The infer keyword

infer declares a type variable inside the condition and lets TypeScript capture whatever type fits that position:

typescript
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T; type A = UnwrapPromise<Promise<string>>; // string type B = UnwrapPromise<number>; // number -- passes through unchanged

Stack infer to pull out tuple heads or function params:

typescript
type Head<T> = T extends [infer First, ...infer Rest] ? First : never; type H = Head<[string, number, boolean]>; // string

How the compiler resolves this

TypeScript tests assignability structurally in a single-pass resolution. For distributed conditionals, it runs the condition once per union member when T is in a bare generic position. Zero runtime cost. The whole mechanism compiles away to plain JavaScript.

One subtlety: if T is still unresolved inside a generic function body, the compiler keeps the conditional unevaluated until the call site provides a concrete type.

Common mistakes

Expecting distribution when T is not bare

typescript
// Distributes -- T is naked here type Id<T> = T extends any ? T : never; type X = Id<string | number>; // string | number // Does NOT distribute -- T is wrapped in Array<> type Wrapped<T> = Array<T> extends any ? T : never; type Y = Wrapped<string | number>; // string | number -- no split

Distribution only triggers when the type being tested is the naked generic T, not T wrapped in something else.

Using any in the condition widens literals

typescript
// Bad -- "a" widens to string type ToArray<T> = T extends any ? T[] : never; type Leak = ToArray<"a" | "b">; // string[] // Good -- literals preserved type ToArraySafe<T> = T extends unknown ? T[] : never; type Fixed = ToArraySafe<"a" | "b">; // "a"[] | "b"[]

unknown preserves literal types. any triggers fresh inference and often widens them.

Forgetting that never extends U is always true

typescript
type A = never extends string ? true : false; // true

never is a subtype of every type, so it passes any extends check. Union members that evaluate to never drop out of the result automatically. That is usually the intended behavior in filters like Exclude, but it surprises people the first time they see it.

Real-world usage

  • React Query: UseQueryResult<TData> uses conditional inference to unwrap async data types.
  • Zod: z.infer<typeof schema> resolves through T extends z.ZodType ? T['_output'] : never.
  • tRPC: procedure types branch on TRPCError to build typed Result<Success, Failure> wrappers.
  • TypeScript stdlib: Exclude, Extract, NonNullable, ReturnType, Parameters are all conditional types under the hood.

Follow-up questions

Q: Write a conditional type that extracts all parameter types of a function.
A: type Params<T> = T extends (...args: infer P) => any ? P : never; Here P captures the full parameter tuple, so Params<(a: string, b: number) => void> gives [string, number].

Q: What is the difference between T extends U ? X : Y and [T] extends [U] ? X : Y?
A: The bracket version disables distribution. Without brackets, a union T splits and the condition runs per member. With brackets, T is treated as one type regardless of whether it is a union.

Q: Why does never extends string evaluate to true?
A: never is the bottom type, a subtype of everything. So it passes any extends check. When filtering a union, members that become never fall out of the result, which is exactly the behavior Exclude and NonNullable rely on.

Q: How do conditional types interact with mapped types?
A: You can combine them to filter object keys. type Filter<T, U> = { [K in keyof T]: T[K] extends U ? T[K] : never } keeps only values assignable to U. Append [keyof T] to collapse the object to a union of matching values.

Q: (Senior) Why do both T extends any and T extends unknown distribute, but [T] extends [any] does not?
A: Distribution is triggered by the position of bare T in the condition, not by what T is tested against. Wrapping T in [T] changes its position from bare generic to tuple member. That single structural change stops the distribution mechanism entirely.

Examples

Extracting return type with infer

The pattern behind TypeScript's built-in ReturnType<T>:

typescript
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; async function fetchUser(): Promise<{ id: number; name: string }> { return { id: 1, name: "Alice" }; } type FetchResult = MyReturnType<typeof fetchUser>; // Promise<{ id: number; name: string }> // Combine with Awaited to unwrap the promise: type UserData = Awaited<FetchResult>; // { id: number; name: string }

infer R captures whatever the function returns. If T is not callable, the result is never.

Filtering a union with distribution

typescript
type OnlyStrings<T> = T extends string ? T : never; type Mixed = "admin" | "user" | 42 | true; type StringRoles = OnlyStrings<Mixed>; // "admin" | "user"

TypeScript runs OnlyStrings on each union member separately. Number and boolean become never and drop out of the final union. This is exactly how Extract<T, string> works internally.

Recursive DeepPartial

typescript
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T; interface Config { server: { host: string; port: number; }; debug: boolean; } type PartialConfig = DeepPartial<Config>; // { // server?: { host?: string; port?: number }; // debug?: boolean; // }

The condition T extends object splits objects from primitives. Primitives pass through unchanged; objects get recursively wrapped. I have used this pattern when building API clients where patch endpoints accept updates at any nesting depth, without defining a separate partial type for each nested interface.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?