The first time I saw a TypeScript signature like
I closed the tab. The combination of extends in a type position, the ? ternary, the infer keyword, and the recursive call read like noise. Three years and a lot of library code later, I write this kind of type comfortably and reach for it about once every two weeks. The trick was not memorising more rules; it was understanding that conditional types are pattern matching on types, and infer is the variable binding for the part you want to extract.
The argument I want to make in this piece is that conditional types are not advanced. They have one syntactic shape, one inference rule, and a small set of behaviours when they meet union types. The library code that uses them looks intimidating because it composes the same building blocks deeply, but every level is the same shape. Once that lands, the standard utility types stop being magical and start being decomposable.
The basic shape
A conditional type is the type-level version of a ternary expression. It picks between two types based on whether one type is assignable to another.
The condition T extends string is a structural assignability check, not an inheritance check. If T can be used everywhere string is expected, the condition is true. The result type is whatever you put after the ?; the alternative is whatever you put after the :.
Reading order is the same as a value-level ternary: condition, then the true branch, then the false branch. There is no other syntactic surprise.
The single inference rule: infer
The infer keyword introduces a type variable inside the extends clause. The compiler tries to find a type that, when substituted for the variable, makes the condition succeed. If it can, the variable is bound and is in scope in the true branch.
The condition T extends (infer U)[] says "if T is an array of some type U, bind U to that type". The compiler matches the array shape, finds the element type, and substitutes it. In the true branch, U is the element type and is the result.
This is the entire mechanic. Conditional type plus infer. Every utility type built on conditionals (ReturnType, Parameters, Awaited, InstanceType, ConstructorParameters) is a tiny variation on this shape.
Reading the standard utility types as conditionals
The fastest way to internalise conditionals is to read the actual definitions of the utility types you already use. The TypeScript lib has them; here are the ones I find most clarifying.
Five definitions. Every one is a conditional plus zero, one, or two infer slots. Awaited is the most complex and it is just three nested conditionals; the recursion is what makes nested promises resolve.
The pattern that unlocks them all: read extends as "matches the shape", read infer X as "and bind the part there to X", and read the rest as "now use X to construct the result".
Distributive conditional types
When the type inside the conditional is a "naked" type parameter (just T, not T[] or T | null), and you pass a union to it, the conditional distributes over the union. The compiler applies the conditional to each member of the union separately and then unions the results.
The compiler walked T = 'a', T = 'b', T = 1, T = 2 separately. The first two yielded themselves, the last two yielded never. The final union is 'a' | 'b' | never | never, which simplifies to 'a' | 'b'.
This is exactly what Extract does. It is also what makes Exclude work: T extends U ? never : T, applied to each member of T, gives back only the members not assignable to U.
To opt out of distribution, wrap the type parameter in a tuple:
The bracket trick is the standard workaround. Use it whenever you want the conditional to operate on the union as a whole rather than per-member.
Practical use: type-safe API responses
A real example I shipped last quarter. The API response shape depends on which endpoint was called, and we wanted the consumer's code to know which response variant it was getting based on the endpoint string.
The conditional logic is hidden inside ResponseFor, which uses indexed access (ApiSchema[P]['response']) rather than an explicit conditional. But the underlying machine is the same: a generic parameter constrained to the keys of an object type, with the result derived from the matching value. The consumer types are exact for every endpoint, with no any, no manual cast.
Push this further with conditionals and you can derive the request shape, the path-parameter type, and the query-string type from the same schema. I have built a small typed-API client this way; the schema is the source of truth, and every helper type is a conditional or indexed-access derivation from it.
A pitfall: conditionals are not for runtime checks
The compiler's view of types is erased at runtime. A conditional type tells you what type the compiler believes a value to be; it does not generate any runtime check.
The function knows at runtime whether x is an array, but the return type IsArray<T> is true or false depending on what the compiler thinks T is, which the compiler does not always know precisely. The fix is type predicates (x is U syntax), not conditional types.
Type predicates are the bridge from runtime checks to type narrowing. Conditional types are a different tool, used at the type level only. Confusing them is the most common mistake I see in PRs that try to be clever.
Recursion in conditionals
Recursive conditional types let you walk into nested structures. The recent versions of TypeScript have proper support; older versions had a depth limit that caused mysterious failures on slightly deep types.
This produces a fully-frozen view of any object type, recursing into nested objects. The pattern is: base case (a non-object, return as-is), recursive case (an object, map every property to the recursive form).
Recursion is where conditional types start feeling powerful. Combined with mapped types (the [K in keyof T] form), they let you express transformations that would be impossible to write by hand.
A mental checklist for any conditional-type problem
When I am writing a conditional type and the compiler is fighting me, I run through these questions in order:
| Question | Why it matters |
|---|---|
| Is the type parameter "naked" or wrapped? | Naked params distribute over unions; wrapped ones do not |
Does the extends shape really match what I think it does? | Structural assignability is broader than nominal |
Is infer in the right position? | infer only works inside the extends clause |
Should this branch be T, never, or something derived? | never is the right default for "no match" cases that will be filtered out by union distribution |
| Am I hitting recursion depth limits? | Restructure the recursion or split into simpler steps |
The five questions cover almost every conditional-type debugging session I have run. The compiler's error messages around conditionals can be confusing; the questions are usually more productive than reading the error directly.
The mental model is small; the practice is what takes time
The mechanic of conditional types is shockingly small. One ternary shape, one inference keyword, one distribution rule when unions show up. The hard part is not the rules; it is recognising when a problem has the shape that conditionals solve, and writing the smallest conditional that produces the type you want.
The way to get there is to read the standard library definitions, write a few utilities of your own, and resist the urge to write a five-deep nested conditional when two named intermediate types would be clearer. Every codebase I have worked in has at least one type that read better as a chain of named conditionals than as one mega-expression, and the cost of that decomposition is zero at runtime.
I check my own conditional-type code with a single rule: if the type alias takes more than ten seconds to read aloud, refactor. Future readers will appreciate the readable version more than the clever one. The compiler does not care which form you wrote.
