A conditional type picks one of two types based on whether one type is assignable to another. It is TypeScript's ternary operator, applied at the type level.
The syntax is:
type Result = A extends B ? C : D;The word extends is the confusing part. This is not class inheritance. It means assignability: can a value of type A be used where a B is expected? If yes, resolve to C. If no, resolve to D.
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hello">; // "yes" - string literals are assignable to stringThis is purely a compile-time check. Nothing happens at runtime.
The official docs for the feature live in the TypeScript handbook, under conditional types. The two parts that cause the most confusion in real code are distribution over unions and pattern matching with infer.
infer is what makes conditional types genuinely useful. It lets you extract a type from inside another type while you're pattern-matching it.
type UnwrapPromise<T> = T extends Promise<infer Inner> ? Inner : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number (not a Promise, so returns T)You're saying: "if T matches Promise<something>, capture that something into Inner." TypeScript figures out what Inner must be.
This is how ReturnType<T> is implemented in the standard library:
type ReturnType<T extends (...args: any) => any> = T extends (
...args: any
) => infer R
? R
: never;A few more patterns that come up in real code:
// Extract the first argument type of a function
type FirstArg<T extends (...args: any) => any> = T extends (
first: infer F,
...rest: any
) => any
? F
: never;
type F = FirstArg<(id: string, options: { limit: number }) => void>;
// F = string
// Unwrap an array element type
type ElementOf<T> = T extends Array<infer E> ? E : T;
type E = ElementOf<string[]>; // string
type F = ElementOf<number>; // number (passthrough)One thing worth noting: you don't always need infer. If the type is accessible through indexing, that's simpler:
// No need for infer here
type Value = Record<string, number>[string]; // numberSave infer for when you need to pattern-match a shape that TypeScript can't reach through direct indexing.
This is where most conditional type bugs come from.
When the type being checked (T) is a naked type parameter (not wrapped in a tuple, object, or anything else), TypeScript automatically distributes the condition across each member of a union:
type ToArray<T> = T extends any ? T[] : never;
type A = ToArray<string | number>;
// TypeScript expands this to:
// ToArray<string> | ToArray<number>
// = string[] | number[]That's often exactly what you want. But sometimes it isn't:
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string | number>;
// = "yes" | "no" (distributed - each member checked separately)If you want to check the whole union against string, wrap both sides in square brackets to opt out of distribution:
type IsString<T> = [T] extends [string] ? "yes" : "no";
type A = IsString<string | number>;
// = "no" (the union as a whole is not assignable to string)The [T] extends [string] form checks assignability once on the tuple, so distribution doesn't kick in.
Say you have a union of app events:
type AppEvent =
| { type: "user.created"; payload: { id: string; email: string } }
| { type: "order.placed"; payload: { orderId: string; amount: number } }
| { type: "item.removed"; payload: { itemId: string } };You can extract the payload for a specific event type without conditional types, because Extract and index access is enough:
type PayloadFor<T extends AppEvent["type"]> = Extract<
AppEvent,
{ type: T }
>["payload"];
type UserCreatedPayload = PayloadFor<"user.created">;
// { id: string; email: string }But if your event structure is more complex or the shape varies, infer earns its keep:
type ExtractPayload<T, K extends string> = T extends {
type: K;
payload: infer P;
}
? P
: never;
type P = ExtractPayload<AppEvent, "order.placed">;
// { orderId: string; amount: number }When the return shape depends on a literal argument, a conditional return type ties the two together in one signature:
type FetchResult<T extends "raw" | "parsed"> = T extends "raw"
? Buffer
: { data: unknown; status: number };
function fetch<T extends "raw" | "parsed">(
url: string,
format: T,
): Promise<FetchResult<T>>;Callers get the right return type automatically based on what they pass for format. No overloads needed.
Distribution does the real work in this utility type, so the body is a single line:
type StripNullish<T> = T extends null | undefined ? never : T;
type A = StripNullish<string | null | undefined>; // stringThe distributive behavior is what makes this work. TypeScript applies the condition to each union member, strips the nullish ones, and merges the rest. This is exactly how the built-in NonNullable<T> works.
Conditional types add cognitive overhead. Before writing one, check if the problem fits a simpler tool.
Function overloads are often cleaner when you need different return types based on argument types:
function process(input: string): string[];
function process(input: number): number;
function process(input: string | number): string[] | number {
// ...
}Discriminated unions work better when you're narrowing based on a shared property in application code:
type Result = { ok: true; value: string } | { ok: false; error: Error };Use conditional types when:
If you're in application code and squinting at the type signature, it's probably overengineered. Discriminated unions or overloads will be easier for whoever reads it next, including you.
For a longer worked example, I wrote a similar deep dive into building a type-safe groupBy function in TypeScript that puts some of these patterns to use.
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.