Conditional Types: Type-Level If Statements


Conditional types are TypeScript's way of writing if-else logic at the type level. Just like a ternary expression picks between two values based on a condition, a conditional type picks between two types based on whether a type relationship holds. They're what makes utility types like NonNullable, ReturnType, and Extract possible. In this post, you'll learn how conditional types work, how distribution over union types makes them powerful, and how to use them to build your own type-level utilities.
What You'll Learn
Conditional types use the syntax T extends U ? X : Y — if T is assignable
to U, the type resolves to X, otherwise Y. They're evaluated lazily when
TypeScript has enough information to make the decision.
What Are Conditional Types
Conditional types let you express a choice between two types based on a type-level condition. The condition is an extends check: does the left-hand type satisfy the right-hand type's constraint?
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true — string literal extends stringThe extends here doesn't mean inheritance. It means "is assignable to." If T can be used wherever U is expected, the condition is true.
Basic Conditional Type Syntax
The pattern is always T extends U ? TrueType : FalseType. You can use any types on either side:
type Flatten<T> = T extends Array<infer Item> ? Item : T;
type Str = Flatten<string[]>; // string
type Num = Flatten<number[]>; // number
type Raw = Flatten<boolean>; // boolean — not an array, returns T itselfThis Flatten type unwraps an array type to its element type. If T is not an array, it returns T unchanged. The infer keyword is covered in the next post — for now notice how conditional types can extract type information.
Distributive Conditional Types
When you apply a conditional type to a union type, TypeScript distributes the condition over each member of the union:
type IsString<T> = T extends string ? "yes" : "no";
type Result = IsString<string | number | boolean>;
// Distributes to:
// IsString<string> | IsString<number> | IsString<boolean>
// = "yes" | "no" | "no"
// = "yes" | "no"This distribution only happens when T is a naked type parameter — a bare type variable, not wrapped in anything. If you wrap T, distribution is suppressed:
type IsStringNonDistributive<T> = [T] extends [string] ? "yes" : "no";
type Result1 = IsStringNonDistributive<string | number>; // "no"
// [string | number] extends [string] — the whole union is checked at onceDistribution Gotcha
Distributive behavior is the default for naked type parameters. Wrapping in a
tuple [T] suppresses it. This matters when you want to check whether an
entire union satisfies a condition, rather than filtering it member-by-member.
Filtering Union Members
Distribution makes conditional types a powerful filter for union types. You can remove types from a union based on any condition:
// Keep only types that extend a given constraint
type Filter<T, U> = T extends U ? T : never;
// never disappears from unions — it's the empty type
type OnlyStrings = Filter<string | number | boolean | null, string>;
// = string
type OnlyObjects = Filter<string | object | number | null, object>;
// = objectnever is the identity element of union types — adding never to a union removes it, just like adding 0 to a sum. This is how Extract and Exclude work in TypeScript's standard library:
// From TypeScript's lib.es5.d.ts
type Extract<T, U> = T extends U ? T : never;
type Exclude<T, U> = T extends U ? never : T;
type StringOrNumber = Extract<string | number | boolean, string | number>;
// = string | number
type NoNull = Exclude<string | number | null | undefined, null | undefined>;
// = string | numberNested Conditional Types
You can chain conditional types just like ternary expressions for multi-branch logic:
type TypeName<T> = T extends string
? "string"
: T extends number
? "number"
: T extends boolean
? "boolean"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T extends Function
? "function"
: "object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<42>; // "number"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<{ a: 1 }>; // "object"Deep nesting can get hard to read. When you have more than three branches, consider whether a mapped type approach might be clearer.
Formatting Tip
Format nested conditional types with aligned indentation (like the example above). TypeScript will still parse it correctly, and your teammates will thank you.
Real-World Patterns
Here's a practical conditional type that flattens nested Promise types — similar to how Awaited works in TypeScript's standard library:
// Recursively unwrap Promise types
type Awaited<T> = T extends null | undefined
? T
: T extends object & { then(onfulfilled: infer F, ...args: infer _): any }
? F extends (value: infer V, ...args: infer _) => any
? Awaited<V>
: never
: T;
// Simpler version for most use cases
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<Promise<Promise<number>>>; // Promise<number> — one level
type C = UnwrapPromise<boolean>; // booleanAnother common pattern is building an API that returns different types based on input:
type ApiResponse<T> = T extends "json"
? { data: unknown; headers: Headers }
: T extends "text"
? string
: T extends "blob"
? Blob
: never;
function fetchData<T extends "json" | "text" | "blob">(
url: string,
format: T
): Promise<ApiResponse<T>> {
// implementation
return fetch(url).then((r) => r[format]() as any);
}
// TypeScript knows exactly what type you get back
const result = await fetchData("/api/data", "json");
// ^? { data: unknown; headers: Headers }Type-Safe APIs
Conditional types let you encode the relationship between input and output at the type level. The caller gets precise return types without overloads or casting.
Best Practices
Use conditional types when overloads become unwieldy. If a function has 3+ overloads that follow a pattern, a conditional type may express that pattern more cleanly.
Prefer simple over clever. A conditional type that requires a comment to explain is a sign to reconsider. If you can express the same constraint with generics + constraints, do that instead.
Avoid overly deep nesting. More than three levels of nested conditional types is a maintenance burden. Break them into named intermediate types.
Test your conditional types. Write a few type assertions to verify your conditional type works as expected across edge cases:
type Assert<T extends true> = T;
type Equals<A, B> = A extends B ? (B extends A ? true : false) : false;
type Test1 = Assert<Equals<Filter<string | number, string>, string>>;
type Test2 = Assert<Equals<Filter<boolean | null, never>, never>>;Anti-Pattern
Don't use conditional types as a workaround for missing type information. If
you find yourself writing T extends any ? ... : never just to "activate"
distribution, step back and reconsider the overall design.
What Is Next
Now that you understand how conditional types choose between two types, the next post dives into the infer keyword — the tool that lets you extract types from within a conditional check. infer is what makes patterns like ReturnType<F> and Parameters<F> possible.
Key Takeaways
- Conditional types use
T extends U ? X : Yto choose between two types based on assignability - Applied to union types with a naked type parameter, conditional types distribute over each member
- Wrapping in
[T]suppresses distribution when you want to check the whole union neveracts as the empty type in unions, making it the natural result for "filter out" branches- Standard utility types like
Extract,Exclude, andNonNullableare all conditional types
Phase 5 Begins
Welcome to the type system mastery phase. The tools you build from here — conditional types, mapped types, template literal types — are what separate TypeScript power users from everyone else.