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.
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.
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.
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.
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 : string → never. 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.
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.
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.
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.”
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.
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.
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.
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()).
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.
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.
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.
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.
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.
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.
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.
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/Extractwork. - Stopping distribution —
[T] extends [U] NonNullableis also a one-line distribute +neverinfer— capture types like variables inside a conditionalReturnType<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.