Updated Jun 22, 2026

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 and 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 the simpler cousins of it 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"). That's it. 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, so you read this without dread: most application code never needs to write any of this. You'll mostly read it, in the type definitions of libraries you use. We'll spend the last section on 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 exact same shape, but operating on types instead of values.

📝 Conditional typeT extends U ? X : Y. Read it as: "if the type T is assignable to U, the result is type X; otherwise it's type Y." The extends here doesn't mean inheritance — it's asking a yes/no question: does T fit into U?

Start with the simplest possible example, which does nothing useful but makes the mechanics obvious:

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

What just happened: IsString<T> is a type that takes another type T as input (that's what the <T> is — a type parameter, like a function argument but for types). When you ask for IsString<"hello">, TypeScript checks "is the string literal "hello" assignable to string?" — yes — so the result is the type true. For IsString<42>, the number 42 is not a string, so you get false. The type literally decided its own value based on its input.

Now a genuinely useful 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. Here's its actual definition:

type MyNonNullable<T> = T extends null | undefined ? never : T;

type Cleaned = MyNonNullable<string | null | undefined>; // string

What just happened: 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 (a union with never in it just drops the never). If no, it keeps the type as-is. Feed it string | null | undefined and the null and undefined arms become never and disappear, leaving you with string. (Why it processes each union member separately is the surprising part we'll get to in the distributive section — hold that thought.)

💡 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 don't just want a 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 type being matched and binds it to a name you can use in the true branch. Think of it as a placeholder that says "I don't know what goes here yet — match anything, and call it R."

The classic example is extracting a function's return type. Here's a simplified version of the built-in ReturnType<T>:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getUser() {
  return { id: 1, name: "Ada" };
}

type User = MyReturnType<typeof getUser>; // { id: number; name: string }

What just happened: The conditional asks "does T match the shape some function returning something?" The infer R sits in the return-type position, meaning "match whatever the return type is and capture it 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 at all, the match would fail and you'd get never. (typeof getUser grabs the type of the function value — we covered typeof in Phase 5.)

The same trick pulls out parameter types, which is exactly how the built-in Parameters<T> works:

type MyParameters<T> = T extends (...args: infer P) => any ? P : never;

function greet(name: string, times: number) {}

type Args = MyParameters<typeof greet>; // [name: string, times: number]

What just happened: 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, so use those. The point is that 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 people who've used TypeScript for years. 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 used bare on the left of extends) distributes over unions. It's the reason 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 happen with a conditional that wraps each type in an array:

type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<string | number>; // string[] | number[]

What just happened: You might have expected (string | number)[] — one array of mixed values. Instead you got string[] | number[]either an array of strings or an array of numbers. That's distribution: TypeScript split string | number into string and number, ran ToArray on each (string[], then number[]), 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, you 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
type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;

type Result2 = ToArrayNoDistribute<string | number>; // (string | number)[]

What just happened: By writing [T] extends [any] instead of T extends any, you wrapped the parameter in a one-element tuple. Now TypeScript 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 the [T] wrapper is the fix. Naming it is half the battle; now you'll recognize it.

Template literal types — building string types from patterns

Conditional types branch on types. Template literal types do something different: they build new string literal types by stitching together pieces, using the exact same backtick syntax as JavaScript template strings.

📝 Template literal type — a string literal type built from a pattern, e.g. `on${string}`. You interpolate other types into a string template, and the result is a type describing strings that match 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 all by hand. Say you have a set of event names and want the "handler" versions (clickonClick):

type EventName = "click" | "focus" | "blur";

type HandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

What just happened: The template `on${Capitalize<EventName>}` interpolated each member of the EventName union into the pattern. Capitalize<T> is a built-in helper that 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:

type Method = "GET" | "POST";
type Path = "/users" | "/posts";

type Route = `${Method} ${Path}`;
// "GET /users" | "GET /posts" | "POST /users" | "POST /posts"

const valid: Route = "POST /users"; // ok
const bad: Route = "DELETE /users";
//    ~~~ Type '"DELETE /users"' is not assignable to type 'Route'.

What just happened: The template combined every Method with every Path — TypeScript takes the cross-product of the two unions, giving all four valid route strings. Assigning "POST /users" is fine because it's 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 will autocomplete the valid routes as you type. That autocomplete-from-strings experience is the whole appeal.

When to reach for this — and when not

Now the honest 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 the 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 library authors so that thousands of callers get great autocomplete and safety. Knowing how to read 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 TypeScript compiler and your 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. That's the demystification to end on: when Prisma gives you fully-typed results that exactly match 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 — and now you can read the trick.

Recap

  1. A conditional type T extends U ? X : Y is a ternary for types: it checks whether T is assignable to U and resolves to one branch or the other. Resolving a branch to never is how "filter out X" utilities like NonNullable delete types from a union.
  2. infer captures a piece of the matched type inside the extends clause — it's how ReturnType grabs a function's return type and Parameters grabs its argument list. Whenever a library type "reaches inside" yours, there's an infer doing it.
  3. Distributive conditional types: a conditional over a union runs once per member and rejoins the results. It's usually what you want, surprises you when it isn't, and is suppressed by wrapping the parameter in a tuple ([T] extends [U]).
  4. 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.
  5. You'll mostly read these in library .d.ts files, not write them — everyday app code rarely needs them.
  6. ⚠️ 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 three 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]`."
  }
]

← Phase 10: Utility & Mapped Types · Guide overview · Phase 12: Typing the Real World →

Check your understanding

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>`?

Was this page helpful?