TypeScriptPublished 4/18/2024

Conditional Types and Template Literal Types: Making TypeScript Compute Your Types

How Conditional Types branch at the type level, how infer pulls values out of nested types, and how Template Literal Types build new string literal types from templates — so you can write TypeScript types that are more precise and infer themselves automatically.

If you've written enough TypeScript, you've hit this need before: a type shouldn't just sit there — it should be computed. "If this type is an array, give me its element type." "Take every key on this object and turn it into an event name like onClick." You can't pull that off with a plain interface or a simple type alias. You need Conditional Types and Template Literal Types.

This post walks through how these two tools work, why they're designed the way they are, and how combining them solves real type-manipulation problems you'll actually run into.

Conditional Types: the type system's ternary operator

Conditional Types look a lot like JavaScript's ternary operator:

ts
type IsString<T> = T extends string ? true : false
 
type A = IsString<'hello'> // true
type B = IsString<42> // false

Read T extends U ? X : Y as "if T is assignable to U, the result is X; otherwise it's Y." extends here doesn't mean inheritance — it means "is compatible with," checking whether T fits the shape of U.

On its own, this example doesn't look like much — you'd rarely write IsString<"hello"> directly in real code. The real power shows up once you pair it with generics.

Distribution: what happens when a conditional type meets a union

When the T in a Conditional Type is itself a union type, and T appears as a naked type parameter on the left side of extends, TypeScript automatically distributes the conditional over every member of the union, checking each one separately and merging the results back into a new union. This behavior is called Distributive Conditional Types.

Guess what this produces before you scroll down:

ts
type ToArray<T> = T extends unknown ? T[] : never
 
type Result = ToArray<string | number>
// distributes into ToArray<string> | ToArray<number>
// which is string[] | number[]

When string | number goes in, it isn't evaluated as one bundled unit. It first splits into string and number, each runs through T extends unknown ? T[] : never on its own, and the results — string[] and number[] — get joined back together as a union. This is especially handy for utility types that filter specific members out of a union:

ts
type ExcludeString<T> = T extends string ? never : T
 
type Result2 = ExcludeString<string | number | boolean>
// number | boolean

never gets automatically dropped from a union, so the string branch effectively vanishes, leaving just number | boolean. This is exactly how TypeScript's built-in Exclude<T, U> works under the hood.

infer: pulling a type out from inside another type

Conditional Types have a key partner: the infer keyword. It lets you declare a new type variable mid-condition and have TypeScript infer what sits in a given position.

The classic example is extracting a function's return type:

ts
type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never
 
function getUser() {
  return { id: 1, name: 'Ada' }
}
 
type User = MyReturnType<typeof getUser>
// User is { id: number; name: string }

This says: if T matches the shape of a function, mark its return position as infer R and let TypeScript figure out what R should be, then return R as the result. It's exactly how TypeScript's built-in ReturnType<T> is implemented.

infer also works on wrapper types like arrays and Promises, unwrapping whatever's inside:

ts
type Unwrap<T> = T extends Promise<infer U> ? U : T
 
type A2 = Unwrap<Promise<string>> // string
type B2 = Unwrap<number> // number — wasn't a Promise, so it passes through unchanged

Template Literal Types: building new types from string templates

Conditional Types and infer cover a lot of ground, but there's still a gap: what if what you need to manipulate isn't a type's shape, but the content of a string itself? Say, turning "click" into "onClick". That's where Template Literal Types come in.

The syntax is basically JavaScript's template literals, except what you're interpolating are types:

ts
type EventName<T extends string> = `on${Capitalize<T>}`
 
type ClickEvent = EventName<'click'> // "onClick"
type HoverEvent = EventName<'hover'> // "onHover"

Capitalize<T> is a built-in utility type that uppercases the first letter of a string. It comes with siblings — Uncapitalize, Uppercase, Lowercase — all built specifically for use with Template Literal Types.

If T is a union, Template Literal Types expand and combine every possibility automatically:

ts
type Direction = 'top' | 'bottom'
type Size = 'small' | 'large'
 
type ClassName = `${Direction}-${Size}`
// "top-small" | "top-large" | "bottom-small" | "bottom-large"

All four combinations get generated in one shot — great for typing CSS-in-JS class names, or constraining a prop to a specific string naming pattern.

Putting it together: real type manipulation

The real payoff comes when you combine Conditional Types, infer, and Template Literal Types all at once. A common case: you have an object type and want to auto-generate the corresponding getter method names.

ts
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
 
interface Person {
  name: string
  age: number
}
 
type PersonGetters = Getters<Person>
// equivalent to:
// {
//   getName: () => string;
//   getAge: () => number;
// }

The as here is the key remapping syntax for Mapped Types, paired with a Template Literal Type to rename each key from name to getName. The string & K intersection narrows K — which could be string | number | symbol — down to the string that Template Literal Types actually accept.

Now the reverse: given a set of event handler prop names (like onClick), derive the underlying event name (like click):

ts
type ExtractEventName<T extends string> = T extends `on${infer Rest}` ? Uncapitalize<Rest> : never
 
type E1 = ExtractEventName<'onClick'> // "click"
type E2 = ExtractEventName<'onHover'> // "hover"
type E3 = ExtractEventName<'click'> // never — doesn't match the "on" prefix pattern

This chains all three tools together: a Template Literal pattern on${infer Rest} checks whether the string starts with "on"; if it matches, infer pulls the remainder into Rest; and Uncapitalize lowercases the first letter back down.

Wrapping up

Conditional Types give types the ability to branch — and when you use a naked type parameter, they automatically distribute over unions. infer lets you reach into a conditional check and pull out a piece of a type to reuse. Template Literal Types bring string-templating into the type system, so types can be concatenated and expanded just like strings. None of these three is hard to understand on its own — the real value shows up when you combine them, letting TypeScript compute brand-new, precise types from existing ones that update automatically as the source types change. That's the actual magic behind a lot of type-safe libraries out there, like strongly-typed routers and form libraries.