Lesson 37 of 80
A conditional type can reference itself in its own true branch — the same way a recursive function calls itself — and that's how TypeScript flattens nested arrays or deep-readonlies an object of unknown depth.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
The conditional-types lesson covered T extends U ? X : Y as a type-level if/else. What it didn't cover: X or Y are just types, and nothing stops one of them from being the conditional type itself, called again with a smaller piece of T. That's a recursive conditional type — the exact same idea as a recursive function, just running at compile time instead of runtime.
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
Read this like you'd read a recursive function: base case first (T isn't an object — a string, number, etc. — just return it unchanged), recursive case second (T is an object — make every property readonly, and recurse into each property's own type in case it's an object too). The recursion bottoms out naturally once every nested value is a primitive.
type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T;
type A = Flatten<number>; // number — base case, not an array
type B = Flatten<number[]>; // number — one level unwrapped
type C = Flatten<number[][]>; // number — two levels unwrapped
type D = Flatten<string[][][]>; // string — three levels, still works
Each recursive call peels off exactly one layer of array-ness via infer U, then asks "is this still an array?" If yes, recurse again on the element type. If no — T is no longer an array — that's the base case, and Flatten resolves to whatever's left. You didn't have to know how deeply nested the input was; the type recurses exactly as many times as needed and stops on its own.
Compare this with DeepReadonly<T>: both are shaped identically — a base case that stops the recursion, a recursive case that peels off one layer and calls the same type again on what's left. Once you've internalized one recursive conditional type, you've internalized the pattern for all of them.
Flatten, DeepReadonly, however many recursive calls — is erased entirely when TypeScript compiles to JavaScript. The "recursion" only exists while the compiler is resolving your types; it produces zero recursive function calls, zero stack frames, in the actual running program.Readonly<T> (the built-in utility type) is deliberately shallow, DeepReadonly<T> is not — this is exactly the gap the utility-types lesson flagged and left open. A recursive conditional type is how you actually close it, since the built-in mapped-type approach alone can't reach into nested properties."user.address.city" generated from a nested object's shape, are all recursive conditional types under the hood.Writing DeepReadonly<T> or Flatten<T> from scratch is a step up from the basic conditional-types-and-infer exercise — it specifically tests whether you understand that a type alias can reference itself, and whether you can reason about a base case and a shrinking recursive case the same way you would for a recursive function. It also connects straight back to that earlier lesson's point about never filtering out of unions: recursive conditional types are usually where that composability actually pays off in real code, not toy examples.