Conditional types are one of those TypeScript features that look strange at first, then click, and once they do, you start noticing where they're useful (and where you've been overreaching).
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 stringOccasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.
This is purely a compile-time check. Nothing happens at runtime.
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 the part that bites people the most.
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 - 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 }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.
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.
If this clicked, I wrote a similar deep dive into building a type-safe groupBy function in TypeScript that puts some of these patterns to use.