Conditional types are how TypeScript makes one type depend on another. Exclude, Extract, NonNullable and ReturnType from the standard library are all conditional types, and most “how would you type this?” questions at the intermediate level end up needing one. The syntax is small. The behaviour that trips people up is distribution over unions, which is also the part interviewers ask about.
Reading the syntax
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<"hi">; // "yes"Read T extends string as “is T assignable to string?”. It’s the same relationship the checker uses when you pass an argument to a parameter. If yes, the result is the first branch; if no, the second. Nothing runs at runtime; the compiler picks a branch while checking types and the whole thing disappears from the emitted JavaScript.
That’s worth stating plainly in an interview because candidates sometimes describe conditional types as if they inspect values. They don’t. They inspect types, and they only resolve when the compiler knows enough about T to decide.
You can prove the branch was taken by assigning the wrong literal:
const a: A = "no";cond1.ts(30,7): error TS2322: Type '"no"' is not assignable to type '"yes"'.Distribution: one condition per union member
Now give IsString a union:
type B = IsString<string | number>; // "yes" | "no"You might expect "no", since string | number as a whole isn’t assignable to string. Instead you get "yes" | "no". When the type being checked is a bare type parameter (written as just T, not T[] or [T]), and the argument is a union, TypeScript applies the condition to each member separately and unions the results. string gives "yes", number gives "no".
This is a feature, and it’s how the filtering utilities work:
type NonNullish<T> = T extends null | undefined ? never : T;
type C = NonNullish<string | null | undefined>; // stringEach member is checked. null and undefined map to never, and never vanishes from a union, leaving string. Exclude<T, U> is exactly T extends U ? never : T, and Extract is the mirror image. With a discriminated union, these give you slices by shape:
type ApiResult =
| { status: "ok"; data: string[] }
| { status: "error"; message: string }
| { status: "loading" };
type OnlyErrors = Extract<ApiResult, { status: "error" }>;
type NotLoading = Exclude<ApiResult, { status: "loading" }>;
const nl: NotLoading = { status: "loading" };cond1.ts(32,26): error TS2322: Type '"loading"' is not assignable to type '"error" | "ok"'.The error message is a nice confirmation: after Exclude, the only statuses left are "ok" and "error".
Turning distribution off
Sometimes you want the union treated as one unit. Compare these two:
type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayNoDist<T> = [T] extends [unknown] ? T[] : never;
type D = ToArray<string | number>; // string[] | number[]
type E = ToArrayNoDist<string | number>; // (string | number)[]
const d: D = [1, "a"];
const e: E = [1, "a"];Only the first assignment fails:
cond1.ts(26,7): error TS2322: Type '(string | number)[]' is not assignable to type 'D'.
Type '(string | number)[]' is not assignable to type 'string[]'.
Type 'string | number' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.D is “an array of all strings, or an array of all numbers”, so a mixed array doesn’t fit. E is “an array that may mix both”. Wrapping T in a one-element tuple means the checked type is no longer a naked type parameter, so distribution doesn’t happen. Any wrapper works, but [T] is the convention and readers recognise it.
The never trap
Distribution has a strange edge case that makes a good interview question. never is the empty union. Distributing over zero members produces zero results, which is never:
type IsNever<T> = T extends never ? true : false;
type IsNeverFixed<T> = [T] extends [never] ? true : false;
type F = IsNever<never>; // never, not true
type G = IsNeverFixed<never>; // true
const f: F = true;cond1.ts(28,7): error TS2322: Type 'true' is not assignable to type 'never'.If you’re asked to write IsNever, the bracketed form is the expected answer, and explaining why the naive one returns never shows you understand distribution rather than having memorised it.
Conditional return types in functions
A tempting use is a function whose return type depends on its argument:
type Parsed<T extends string | number> = T extends string ? number : string;
function convert<T extends string | number>(input: T): Parsed<T> {
if (typeof input === "string") {
return Number(input);
}
return String(input);
}Callers get nice types: convert("42") is number, convert(42) is string, and a string | number argument distributes to string | number. But the implementation doesn’t compile:
cond2.ts(5,5): error TS2322: Type 'number' is not assignable to type 'Parsed<T>'.
cond2.ts(7,3): error TS2322: Type 'string' is not assignable to type 'Parsed<T>'.Inside the function, T is still unresolved. Narrowing input with typeof narrows the value, not the type parameter, so the compiler can’t decide which branch of Parsed<T> applies and refuses both returns. This surprises a lot of people. You have three honest options:
- Overloads:
function convert(input: string): number;andfunction convert(input: number): string;above an implementation typedstring | number. Clean for callers and fully checked inside, but a caller holdingstring | numbergetsNo overload matches this call(TS2769) unless you add a third overload for the union. - Keep the conditional signature and cast inside:
return Number(input) as Parsed<T>. Short, but the casts are unchecked, so a wrong branch won’t be caught. - Rethink the API. Two functions,
toNumberandtoText, are often clearer than one clever one.
In an interview, naming the error and explaining why it happens counts for more than which option you pick.
Reading the ones in lib.d.ts
Once distribution clicks, the standard utility types stop looking like magic, and opening lib.es5.d.ts in your editor is a good way to practise. Exclude and Extract are one-line distributive conditionals. ReturnType and Parameters are conditionals that use infer to pull a piece out of a function type. Awaited is a recursive conditional that keeps unwrapping then until it reaches a non-thenable, which is why Awaited<Promise<Promise<number>>> is number.
Not everything is a conditional, though. NonNullable<T> used to be T extends null | undefined ? never : T, and since TypeScript 4.8 it’s defined as T & {}. The result is the same for ordinary unions, but the intersection version behaves better inside generic code because the compiler can simplify it without knowing T. That’s a useful detail if an interviewer asks you to implement NonNullable and then asks whether the real one is written that way.
A practical rule for your own code: if a conditional type needs more than two levels of nesting, give the inner pieces names. type ElementOf<T> and type IsOptional<T, K> are easier to test and to read in error messages than one long expression.
Interview practice
Write a type NonFunctionKeys<T> that gives the keys of T whose values aren’t functions. For { id: number; name: string; save(): void } it should produce "id" | "name". Then explain where distribution is happening.
Answer
type NonFunctionKeys<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any ? never : K;
}[keyof T];The mapped type builds an object whose value at each key is either the key itself or never: { id: "id"; name: "name"; save: never }. Indexing it with [keyof T] collects all the values into a union, and never drops out, leaving "id" | "name".
The point to make about distribution is that there isn’t any in the conditional here. T[K] is not a naked type parameter, so the condition is evaluated once per key by the mapped type, not split across a union. If a property’s type were itself a union like string | (() => void), the condition would see the whole union, fail the extends check, and keep the key. If you wanted mixed properties excluded too, you’d move the test into a helper with a naked parameter, type IsFn<V> = V extends (...args: any[]) => any ? true : false. That helper distributes, so for string | (() => void) it returns boolean, and the mapped type can test true extends IsFn<T[K]> ? never : K to drop any key where at least one member is a function.