TypeScript Advanced #3: Conditional types and infer

In #1 keyof and typeof and #2 Mapped types we saw two tools for transforming types. This post adds branching on top of them — conditional types and infer. Once these come into play, you can write nearly all of the built-in utility types yourself.

Basics — T extends U ? X : Y #

The syntax looks exactly like JavaScript’s ternary operator.

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

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false
type C = IsString<boolean>;  // false

Think of it as an if statement at the type level. If T is a subset of string, return true; otherwise false.

The meaning of extends is slightly tricky — here A extends B means “is A assignable to B?”. It’s not the inheritance extends of classes; it’s a subset check.

extends is a subset check
type T1 = 'hello' extends string ? true : false;          // true
type T2 = string extends 'hello' ? true : false;          // false
type T3 = 'red' extends 'red' | 'blue' ? true : false;    // true
type T4 = 'red' | 'blue' extends 'red' ? true : false;    // false (why? — explained next)

T4 is surprising; we explain in the next section.

Distributive conditional types #

TypeScript’s conditional type has one peculiar behavior — it distributes over union types.

union distribution
type Naked<T> = T extends string ? 'yes' : 'no';

type Result = Naked<'hello' | 42 | true>;
// 'yes' | 'no' | 'no'
// = 'yes' | 'no'

'hello' | 42 | true isn’t evaluated as one whole — each member is evaluated separately. The results are then unioned back together. This is called distributive conditionals.

This behavior is why utilities like Exclude are defined in a single short line.

Exclude implementation
type MyExclude<T, U> = T extends U ? never : T;

type WithoutString = MyExclude<string | number | boolean, string>;
// number | boolean

T is a union, so it distributes, and each member is checked. string becomes string extends string ? never : stringnever. number and boolean stay. Joining the results back gives a union with string removed.

The key point is that never disappears from a union. string | never | number collapses to string | number.

The same pattern gives Extract.

Extract implementation
type MyExtract<T, U> = T extends U ? T : never;

type OnlyString = MyExtract<string | number | boolean, string>;
// string

The reason Exclude showed up inside Omit in the previous post should now be visible — to subtract a set of keys from a key union, you needed distributive conditionals.

When you don’t want distribution — [T] extends [U] #

Sometimes you want to evaluate a union as a whole at once. Wrap the type in a tuple to stop distribution.

Stop distribution
type IsExactlyString<T> = [T] extends [string] ? true : false;

type X = IsExactlyString<string | number>;     // false (evaluated at once)
type Y = IsExactlyString<string>;              // true

The distributive version evaluates each union member separately, so the result mixes into true | false (i.e. boolean). Wrapping in a tuple stops that. It’s a common idiom for checking “is this union exactly a subset of string?”.

NonNullable — built-in but a one-liner #

Built-in that drops null and undefined from a union.

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

type X = MyNonNullable<string | null | undefined>;   // string
type Y = MyNonNullable<number | undefined>;          // number

Another application of the distribute + never pattern. As you get used to utility types, the question “how was this built?” becomes a 5-second answer.

infer — declaring a variable inside a type #

infer is a special keyword that can only be used inside a conditional. It means “capture the type at this position like a variable.”

infer basics
type ElementType<T> = T extends (infer U)[] ? U : never;

type A = ElementType<string[]>;       // string
type B = ElementType<number[]>;       // number
type C = ElementType<boolean>;        // never (not an array)

T extends (infer U)[] means “is T an array of some U? If so, capture that U.” If T is an array, you get the element type; otherwise, never.

The (infer X) pattern creates a kind of matching inside a type. It plays the role of a regex capture group.

Building ReturnType #

One of the most-used built-ins. Pull out just the return type of a function type.

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

function getUser() {
  return { id: 'u1', name: '커티스' };
}

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

(...args: any[]) => infer R — “if it’s a function that takes any args and returns R, capture that R.” If T is a function, its return type is captured into R. The built-in is essentially this one line.

Building Parameters #

Same approach pulls out argument types. This time it’s a tuple, not a single type.

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

function greet(name: string, age: number) {
  return `${name} is ${age}`;
}

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

...args: infer P — “capture the entire rest-parameters slot into P.” It pulls every argument into a tuple.

The reason this is useful — when you want another function to take the same arguments, you don’t have to retype them.

Delegating arguments to another function
function logCall<F extends (...args: any[]) => any>(
  fn: F,
  ...args: Parameters<F>
): ReturnType<F> {
  console.log('호출:', fn.name, args);
  return fn(...args);
}

logCall(greet, '커티스', 30);   // OK, returns string
logCall(greet, 30, '커티스');   // ✗ wrong argument order

A three-line wrapper function that preserves the original function’s signature exactly. Argument checks and the return type both stay accurate.

Awaited — unwrapping Promises #

A common case comes up when handling result types of code like fetch().then(r => r.json()).

Awaited implementation (simple version)
type MyAwaited<T> = T extends Promise<infer U> ? U : T;

type A = MyAwaited<Promise<string>>;             // string
type B = MyAwaited<string>;                       // string (not a Promise — passes through)
type C = MyAwaited<Promise<Promise<number>>>;    // Promise<number> ← only unwraps once

C is surprising — it’s a simple version that unwraps once, so a doubly-wrapped Promise isn’t fully unwrapped. The built-in Awaited unwraps recursively.

Recursive version
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;

type C = MyAwaited<Promise<Promise<number>>>;   // number

MyAwaited<U> calls itself. Keeps unwrapping until T isn’t a Promise. The actual built-in Awaited is more complex because it handles thenable objects, but the core idea is the same.

infer + extends constraints #

You can put a constraint on the infer slot too.

infer extends constraint
type FirstString<T> = T extends [infer U extends string, ...any[]]
  ? U
  : never;

type A = FirstString<['hello', 42, true]>;   // 'hello'
type B = FirstString<[42, 'hello']>;          // never  (first element isn't a string)

This syntax is available since TypeScript 4.7. The first tuple element is captured as U only when it’s a string. This is where people start calling it type-level pattern matching.

Real-world — last return type of a function chain #

A slightly tougher example. Pull out the last function’s return type from a chain of functions.

LastReturn — recursion + infer
type LastReturn<T extends ((...args: any[]) => any)[]> =
  T extends [...any[], infer Last extends (...args: any[]) => any]
    ? ReturnType<Last>
    : never;

const fns = [
  (x: number) => x * 2,
  (x: number) => x + 1,
  (x: number) => `result: ${x}`,
] as const;

type Final = LastReturn<typeof fns>;   // string

Capture the last element of a tuple as Last and pull out its ReturnType. [...any[], infer Last] is the trick — “no matter what’s before, capture the last one.” Patterns like this show up regularly when writing library types.

conditional + mapped — explosive expressiveness together #

In #2 we saw DeepReadonly briefly. With conditionals, you can inspect each field and treat it differently.

readonly only the function fields
type LockMethods<T> = {
  [K in keyof T]: T[K] extends Function ? Readonly<T[K]> : T[K];
};

Or a transform that strips ? only from the optional props.

Make the optionals required, drop the rest
type RequireOptional<T> = {
  [K in keyof T as undefined extends T[K] ? K : never]-?: T[K];
};

type Form = { id: string; nickname?: string; age?: number };
type Filled = RequireOptional<Form>;
// { nickname: string; age: number }

The as clause uses a conditional to filter keys, and the modifier strips optionality. Once the tools come together, the expression feels natural.

Pitfall — extends distributes by default, stay aware #

Forgetting distribution can make the result diverge from your expectations.

Distribution trap
type IsArray<T> = T extends any[] ? true : false;

type A = IsArray<string[] | number>;
// boolean (= true | false)

Each union member was evaluated separately and produced true | false. If your intent was “evaluate the whole thing at once,” wrap in a tuple.

Wrap in tuple
type IsArrayStrict<T> = [T] extends [any[]] ? true : false;

type A = IsArrayStrict<string[] | number>;   // false

Whether you want distribution or not — every time, you need to ask “should this conditional distribute over union members?” Remember that distribution is the default behavior, and stopping it is the explicit choice.

Wrap-up #

What this post covered:

  • Conditional type — T extends U ? X : Y
  • Distributive conditionals — unions auto-distribute. How Exclude/Extract work.
  • Stopping distribution — [T] extends [U]
  • NonNullable is also a one-line distribute + never
  • infer — capture types like variables inside a conditional
  • ReturnType<T>, Parameters<T>, Awaited<T> are all one-line conditional + infer
  • Tuple pattern matching like [...any[], infer Last]
  • Combining conditional + mapped to transform per key

In the next post (#4 Template literal types) we cover the tools for composing string types — the `${...}` pattern and built-in helpers like Capitalize / Uppercase. Used to model route patterns as types or auto-generate event handler names.

X