Conditional & Template Literal Types - Types That Make Decisions
This is the deep end of the type system - the phase where types stop being static labels and start to compute. If you've ever opened a library's .d.ts file, seen T extends (...args: any[]) => infer R ? R : never, and quietly closed the tab, this phase is for you. By the end you'll be able to read that line, and write its simpler cousins yourself.
Here's the one mental model to carry through everything below: a type can be computed from another type. Up to now your types have been fixed shapes - string, Product, User[]. But TypeScript also lets a type branch ("if the input is a string, the result is X, otherwise Y") and pattern-match ("if the input is a function, pull out its return type"). Conditional types are the branching; infer is the pattern-matching; template literal types do the same trick for strings. Everything in this phase is one of those three ideas.
One reassurance up front: most application code never needs to write any of this. You'll mostly read it, in the type definitions of libraries you use. The last section covers exactly when reaching for these tools pays off - and when it makes your code worse.
Conditional types - a ternary for types
You already know the JavaScript ternary: condition ? a : b. Conditional types are the same shape, operating on types instead of values.
📝 Conditional type - T extends U ? X : Y. Read: "if T is assignable to U, the result is X; otherwise Y." The extends here doesn't mean inheritance - it's a yes/no question: does T fit into U?
Start with the simplest example, which does nothing useful but makes the mechanics obvious:
;
; // true
; // false
IsString<T> takes another type T as input (that's what <T> is - a type parameter, like a function argument but for types). Asking for IsString<"hello">, TypeScript checks "is "hello" assignable to string?" - yes - so the result is true. For IsString<42>, 42 is not a string, so you get false. The type literally decided its own value from its input.
Now one you've already used without knowing how it's built. The standard library's NonNullable<T> strips null and undefined out of a type. A conditional-type version of it (the modern standard library uses a shorter T & {} trick, but this spells out the same logic):
;
; // string
For each member of the input, the conditional asks "is this null or undefined?" If yes, it resolves to never - the type with no values, which vanishes from a union. If no, it keeps the type as-is. Feed it string | null | undefined and the null/undefined arms disappear, leaving string. (Why it processes each union member separately is covered in the distributive section below.)
💡 never is the type-level "delete" button. When you want a conditional type to remove something, resolve that branch to never. In a union, never evaporates. This pattern - ... ? never : T - is how almost every "filter out X" utility type is built.
infer - reaching inside a type
A conditional type can ask "does T match this shape?" But often you want more than yes/no - you want to grab a piece of the matched type. That's what infer is for.
📝 infer - used only inside the extends clause of a conditional type. It captures part of the matched type and binds it to a name you use in the true branch. A placeholder that says "match anything here, and call it R."
The classic example is extracting a function's return type - a simplified version of the built-in ReturnType<T>:
;
; // { id: number; name: string }
The conditional asks "does T match the shape some function returning something?" infer R sits in the return-type position, meaning "capture whatever the return type is as R." When T is the type of getUser, the match succeeds and R becomes { id: number; name: string }, which the true branch returns. If T weren't a function, the match would fail and you'd get never. (typeof getUser grabs the type of the function value - covered in Phase 5.)
The same trick pulls out parameter types - exactly how the built-in Parameters<T> works:
;
; // [name: string, times: number]
This time infer P sits in the arguments position, capturing the whole parameter list as a tuple [string, number]. Same mechanism, different slot. You don't need to memorize these - TypeScript ships ReturnType and Parameters built in - but now you can read them when you hover over them in your editor.
💡 infer is how library types "reach inside" your types. Whenever a utility seems to magically know the return type, element type, or resolved value of your Promise, there's an infer doing the reaching. It's the single most common ingredient in advanced library typings - recognizing it demystifies most of them at a glance.
Distributive conditional types - the surprising part
Here's the behavior that catches everyone off guard, including veterans. When the type you pass to a conditional is a union, the conditional doesn't run once on the whole union - it runs separately on each member and combines the results back into a union.
⚠️ This is called distribution, and it's automatic. A "naked" type parameter (T bare on the left of extends) distributes over unions. It's why MyNonNullable<string | null> worked member-by-member earlier instead of asking "is the whole union null | undefined?" - which would have answered "no" and broken everything.
Watch it with a conditional that wraps each type in an array:
;
; // string[] | number[]
You might have expected (string | number)[] - one array of mixed values. Instead you got string[] | number[] - either an array of strings or numbers. That's distribution: TypeScript split string | number into string and number, ran ToArray on each, and rejoined the results with |. The conditional fired twice, once per union member.
This is usually what you want (it's why filtering utilities work), but when it isn't, suppress it by wrapping both sides in a tuple so T is no longer "naked":
// [T] is not a naked type parameter, so distribution is off
;
; // (string | number)[]
Writing [T] extends [any] instead of T extends any wraps the parameter in a one-element tuple. TypeScript now sees the whole union as a single unit, doesn't split it, and you get the combined (string | number)[]. You don't need this often - but when a conditional type gives a weirdly split result you didn't expect, distribution is the culprit, and [T] is the fix.
Template literal types - building string types from patterns
Conditional types branch on types. Template literal types do something different: build new string literal types by stitching together pieces, using the same backtick syntax as JavaScript template strings.
📝 Template literal type - a string literal type built from a pattern, e.g. `on${string}`. Interpolate other types into a string template, and the result describes strings matching that shape. Combined with union types, one template can describe a whole family of valid strings.
The headline use is generating related string types instead of writing them by hand. Say you have a set of event names and want the "handler" versions (click → onClick):
;
;
// "onClick" | "onFocus" | "onBlur"
The template `on${Capitalize<EventName>}` interpolated each member of the EventName union into the pattern. Capitalize<T> uppercases the first letter, so "click" became "Click", then the on prefix gave "onClick". Because EventName is a union, you got a union back: all three handler names, generated automatically. Change EventName and the handler names update with it - no manual list to keep in sync.
A more real-world flavor: typed API routes, where a string must follow METHOD /path:
;
;
;
// "GET /users" | "GET /posts" | "POST /users" | "POST /posts"
; // ok
const bad: Route = "DELETE /users";
// ~~~ Type '"DELETE /users"' is not assignable to type 'Route'.
The template combined every Method with every Path - TypeScript takes the cross-product of the two unions, giving all four valid route strings. "POST /users" is fine, a member of that union; "DELETE /users" is rejected because DELETE was never in Method. You've turned a free-form string into a tightly checked set, and your editor autocompletes the valid routes as you type - the whole appeal.
When to reach for this - and when not
Now the straight-talk part. Everything above is powerful, and that power is a trap if you misjudge when to use it.
💡 You will read these far more than you write them. The vast majority of application code - components, API handlers, business logic - is typed perfectly well with tools from earlier phases: interfaces, unions, generics, the built-in utility types. Conditional and template literal types live mostly in the .d.ts files of libraries, written once by authors so thousands of callers get great autocomplete and safety. Reading them is the daily skill; writing them is the occasional one.
⚠️ Type-level programming can become write-only code, and it can slow your compiler to a crawl. A deeply nested conditional type with three infers and a recursive helper is genuinely hard for the next person (often future-you) to understand, and elaborate type computations make the compiler and editor sluggish. Before building one, ask: is the payoff a real, measurable improvement to the people calling this code? If the answer is "it'd be kind of clever," write the simpler, more verbose type instead. A type you can read at a glance beats a brilliant one you have to decode.
So when is it worth it? When you're building something whose entire value is a great typed API for its callers - a query builder, a router, a form library. When Prisma gives fully-typed results matching the columns you selected, or tRPC autocompletes your server procedures on the client with zero code generation, this is the machinery doing it: conditional types branching on your schema, infer reaching into your function signatures, template literals assembling route strings. It was never magic - it's the three tools you just learned, applied with care.
Recap
- A conditional type
T extends U ? X : Yis a ternary for types: checks whetherTis assignable toUand resolves to one branch or the other. Resolving a branch toneveris how "filter out X" utilities likeNonNullabledelete types from a union. infercaptures a piece of the matched type inside theextendsclause - it's howReturnTypegrabs a function's return type andParametersgrabs its argument list. Whenever a library type "reaches inside" yours, aninferis doing it.- Distributive conditional types: a conditional over a union runs once per member and rejoins the results. Usually what you want, surprises you when it isn't, and is suppressed by wrapping the parameter in a tuple (
[T] extends [U]). - Template literal types build string literal types from patterns with backtick syntax (
`on${Capitalize<E>}`), and crossed with unions they generate whole families of valid strings - great for event names, route strings, and the like. - You'll mostly read these in library
.d.tsfiles, not write them - everyday app code rarely needs them. - ⚠️ Type-level programming can become unreadable and slow the compiler. Reach for it only when the payoff - excellent autocomplete and safety for callers - is real; otherwise prefer the simpler type. It's the machinery behind the "magic" in libraries like Prisma and tRPC.
Quick check
Lock in the core moves - branching, reaching inside, and building strings:
[
{
"q": "What does the conditional type `T extends string ? true : false` resolve to when `T` is `42`?",
"choices": [
"`false` - because the number `42` is not assignable to `string`, so the conditional takes the else branch",
"`true` - because every type extends `string` in TypeScript",
"`never` - because the types don't match",
"A compile error, because you can't compare a number to a string"
],
"answer": 0,
"explain": "A conditional type is a type-level ternary. `extends` asks 'is `T` assignable to `string`?' For `T = 42` the answer is no, so it resolves to the else branch - the type `false`."
},
{
"q": "In `type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never`, what is the role of `infer R`?",
"choices": [
"It captures the function's return type and binds it to `R`, so the `true` branch can return that captured type",
"It declares a new generic parameter that the caller must supply",
"It forces `T` to be a function or the type errors",
"It runs the function `T` and stores the result in `R`"
],
"answer": 0,
"explain": "`infer` is pattern-matching inside the `extends` clause. `infer R` sits in the return-type position and captures whatever the function returns, naming it `R` so the `true` branch can resolve to it. If `T` isn't a function, the match fails and you get `never`."
},
{
"q": "Given `type ToArray<T> = T extends any ? T[] : never`, what is `ToArray<string | number>`?",
"choices": [
"`string[] | number[]` - the conditional distributes over each union member and rejoins the results",
"`(string | number)[]` - one array holding both types",
"`never` - because a union can't extend `any`",
"`any[]` - because the condition is `extends any`"
],
"answer": 0,
"explain": "This is distribution: a naked type parameter over a union runs the conditional once per member, giving `string[]` and `number[]`, then rejoins them as `string[] | number[]`. To get `(string | number)[]` instead, you'd suppress distribution with `[T] extends [any]`."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. What does the conditional type `T extends string ? true : false` resolve to when `T` is `42`?
2. In `type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never`, what is the role of `infer R`?
3. Given `type ToArray<T> = T extends any ? T[] : never`, what is `ToArray<string | number>`?