Advanced Types
The type-level programming layer — conditional types and infer, mapped types over keyof, template literal types, distributive conditionals, and recursive types with their instantiation-depth limits.
16 questions
JuniorTheoryVery commonWhat is a conditional type, and what does T extends U ? X : Y mean?
What is a conditional type, and what does T extends U ? X : Y mean?
A conditional type selects one of two types by testing assignability: T extends U ? X : Y resolves to X when T is assignable to U, and to Y otherwise. It is a compile-time if over types, evaluated by the checker and erased entirely from the emitted JavaScript.
Common mistakes
- ✗Reading
extendshere as class inheritance rather than as an assignability test - ✗Expecting a conditional type to survive into the emitted JavaScript
- ✗Assuming both branches are unioned instead of exactly one being selected
Follow-up questions
- →What happens to a conditional type when the checked type
Tis a union? - →What does the
inferkeyword add inside theextendsclause?
JuniorTheoryVery commonWhat does keyof T produce for an object type?
What does keyof T produce for an object type?
keyof T produces the union of T's property names as literal types: for { a: number; b: string } it is 'a' | 'b'. Combined with an indexed access T[K] it types safe property lookup, as in <K extends keyof T>(o: T, k: K) => T[K]. On a type with a string index signature it widens to string | number.
Common mistakes
- ✗Thinking
keyof Tyields the value types rather than the property names - ✗Expecting a plain
stringinstead of a union of literal key names - ✗Forgetting that a string index signature widens
keyof Ttostring | number
Follow-up questions
- →How does
T[K]indexed access work onceKis constrained bykeyof T? - →What does
keyofgive you for a union type such asA | B?
JuniorTheoryVery commonWhat is a mapped type, and how does { [K in keyof T]: T[K] } work?
What is a mapped type, and how does { [K in keyof T]: T[K] } work?
A mapped type builds a new object type by iterating over a union of keys. { [K in keyof T]: T[K] } walks every key of T and gives each one a value type, so it reproduces T. Readonly<T> and Partial<T> are written exactly this way, adding a readonly or ? modifier during the mapping. The result is a fresh type computed by the checker.
Common mistakes
- ✗Believing a mapped type produces runtime code rather than a compile-time type
- ✗Thinking the value type cannot be transformed during the mapping
- ✗Confusing a mapped type with an index signature — the key set is fixed, not open
Follow-up questions
- →How would you write
Readonly<T>andPartial<T>yourself as mapped types? - →How does an
asclause let a mapped type rename or drop keys?
JuniorCodeCommonHow do you type a JSON value that nests objects and arrays to any depth?
How do you type a JSON value that nests objects and arrays to any depth?
A type alias may reference itself, so Json is a recursive union: type Json = string | number | boolean | null | Json[] | { [k: string]: Json }. Both self-references sit inside an array or an index signature, which defers the recursion, so the checker resolves it lazily and nesting works to any depth.
Common mistakes
- ✗Believing a type alias may never refer to itself
- ✗Reaching for
anyorunknowninstead of expressing the recursion directly - ✗Threading a hand-rolled depth counter a lazily-resolved alias does not need
Follow-up questions
- →What changes if you declare
Jsonas aninterfaceinstead of a type alias? - →When does a recursive type hit the compiler's instantiation-depth limit?
JuniorTheoryCommonWhat is a template literal type, and what can it express?
What is a template literal type, and what can it express?
A template literal type builds string literal types out of other types, written like a JS template string but in type position. Interpolating a union expands to the cross product, so combining 'a' | 'b' with 1 | 2 yields 'a-1' | 'a-2' | 'b-1' | 'b-2'. With Capitalize and infer it can construct or even parse key names entirely at compile time.
Common mistakes
- ✗Thinking it produces a runtime string rather than a set of literal types
- ✗Not realising an interpolated union expands to the cross product of members
- ✗Forgetting
infercan pattern-match inside a template literal type to parse a string
Follow-up questions
- →How would you type an event-handler name derived from an event-name union?
- →How does
inferinside a template literal type let you split a string?
MiddleTheoryCommonWhen does a conditional type distribute over a union, and how do you stop it?
When does a conditional type distribute over a union, and how do you stop it?
A conditional type distributes when the checked type is a naked type parameter and the argument is a union: the check runs on each member and the results are unioned back. That is why NonNullable<string | null> yields string. Wrapping both sides in a tuple — [T] extends [U] ? X : Y — makes the operand non-naked, so the union is checked once as a whole.
Common mistakes
- ✗Expecting distribution when the checked type is not a naked type parameter
- ✗Being surprised that a union argument produces a union result rather than one branch
- ✗Not knowing the tuple wrapper
[T] extends [U]is what turns distribution off
Follow-up questions
- →What does a distributive conditional type produce when
Tisnever? - →How does distribution explain the definition of
Exclude<U, E>?
MiddleTheoryCommonWhat does infer do inside a conditional type's extends clause?
What does infer do inside a conditional type's extends clause?
infer R declares a fresh type variable at that position in the pattern and binds it to whatever the checker matches there. T extends (...a: any[]) => infer R ? R : never captures a function's return type — that is literally how ReturnType<T> is defined. infer is legal only inside a conditional's extends clause, and the bound variable is visible only in the true branch.
Common mistakes
- ✗Trying to use
inferoutside a conditional type'sextendsclause - ✗Referencing the inferred variable in the false branch, where it is not in scope
- ✗Expecting
inferto widen the match instead of binding it exactly
Follow-up questions
- →How is
Awaited<T>written, and why does it have to recurse? - →What happens when one
inferposition matches in several places at once?
MiddleTheoryCommonWhat do the as clause and the +/- modifiers do in a mapped type?
What do the as clause and the +/- modifiers do in a mapped type?
An as clause remaps each key: mapping K to a template literal type renames it, and mapping it to never drops the key entirely. The +/- modifiers add or remove readonly and ? — -? is exactly how Required<T> strips optionality. A homomorphic mapping, one written over keyof T, copies T's existing readonly and ? modifiers unless you override them.
Common mistakes
- ✗Thinking
asretypes the value instead of remapping the key - ✗Missing that mapping a key to
neveris how you drop it - ✗Assuming a homomorphic mapping resets
readonly/?rather than preserving them
Follow-up questions
- →How would you build a getter-per-property type such as
getNamefromname? - →What makes a mapped type homomorphic, and why does that matter for arrays?
MiddleCodeOccasionalType an options object where two fields must be both present or both absent
Type an options object where two fields must be both present or both absent
Union the full shape with a shape whose keys are all optional and typed never: type AllOrNothing<T> = T | { [K in keyof T]?: never }. The second half is a mapped type over keyof T, so it tracks T automatically. Supplying only start matches neither branch — it lacks end for the first and gives never a value for the second — so the compiler rejects it.
Common mistakes
- ✗Assuming two optional fields already constrain each other — they do not
- ✗Intersecting the two halves instead of unioning them, which yields an unusable type
- ✗Giving up on the compiler and pushing the invariant into a runtime check
Follow-up questions
- →How would you extend this to an exclusive-or between two different shapes?
- →Why does an excess-property check matter for the empty-object branch here?
SeniorDebuggingOccasionalDiagnose and fix a type that reports "instantiation is excessively deep"
Diagnose and fix a type that reports "instantiation is excessively deep"
The recursive call sits inside a template literal, so it is not in tail position: every level must stay on the instantiation stack waiting to splice its result, and the checker's depth budget runs out. Thread an accumulator so the branch returns the recursive call directly, concatenating before it recurses — the checker optimises that tail-recursive shape and unrolls it far deeper.
Open full question →Common mistakes
- ✗Blaming the tuple spread or the
inferconstraints instead of the call's position - ✗Not recognising a recursive call wrapped in a template literal is not a tail call
- ✗Capping the input length instead of moving the recursion into tail position
Follow-up questions
- →Why does an accumulator parameter turn this into a tail-recursive type?
- →How would you keep the two-parameter public signature while recursing on three?
SeniorTheoryOccasionalWhy does mapping over A | B differ from mapping over keyof (A | B)?
Why does mapping over A | B differ from mapping over keyof (A | B)?
A homomorphic mapped type — { [K in keyof T]: … } with T a naked parameter — distributes over a union, so Partial<A | B> becomes Partial<A> | Partial<B> and each member keeps its own keys. Mapping over keyof (A | B) is not homomorphic: keyof of a union yields only the keys common to every member, so the result collapses to the shared keys and silently drops the rest.
Common mistakes
- ✗Expecting
keyof (A | B)to give every key rather than only the shared ones - ✗Not realising a homomorphic mapped type distributes across a union argument
- ✗Inlining
keyof Tinto the key source and losing the distribution by accident
Follow-up questions
- →What does
keyof (A | B)evaluate to whenAandBshare no keys at all? - →How does this interact with a discriminated union whose members differ in shape?
SeniorTheoryOccasionalWhy does a recursive conditional type stop compiling once the input grows?
Why does a recursive conditional type stop compiling once the input grows?
The checker caps how deep it will instantiate a type before calling the expansion possibly infinite, and a conditional that unrolls once per element hits that cap and reports "Type instantiation is excessively deep and possibly infinite". Tail-position recursion is optimised and goes far further, so the fixes are to restructure into tail recursion, batch the work, or bound the depth with a counter.
Common mistakes
- ✗Reading the error as a genuine infinite type rather than a depth budget being exhausted
- ✗Not knowing tail-recursive conditional types get a much larger budget
- ✗Trying to raise a compiler memory limit instead of restructuring the recursion
Follow-up questions
- →What makes a recursive conditional type tail-recursive in the checker's eyes?
- →How does an accumulator parameter change the depth a type recursion can reach?
SeniorCodeOccasionalImplement ReturnType<T> yourself, then explain why Awaited<T> must recurse
Implement ReturnType<T> yourself, then explain why Awaited<T> must recurse
type MyReturnType<T extends (...a: any) => any> = T extends (...a: any) => infer R ? R : never — the conditional matches T against a function shape and infer R binds the return position. Awaited<T> cannot stop there: a promise may resolve to another promise, so it re-applies itself to the inferred value — T extends PromiseLike<infer U> ? MyAwaited<U> : T — until the payload is not thenable.
Common mistakes
- ✗Believing
ReturnTypeis an intrinsic rather than a conditional type withinfer - ✗Writing
Awaitedas a single unwrap, which leavesPromise<Promise<T>>half-resolved - ✗Putting the inferred variable in the alias's parameter list instead of the
extendsclause
Follow-up questions
- →How would
Parameters<T>be written with the same technique? - →What does the real
Awaited<T>add beyond this to handle a non-promise thenable?
SeniorDesignRareYour team's shared types package has grown a layer of deeply nested conditional types with several infer positions each. They are correct and well-tested, but a compile error inside one of them prints a 40-line expanded type that nobody can read, IDE hovers show the unexpanded alias instead of the resolved shape, and two developers have already shipped bugs by misreading which branch fires. A junior on the team proposes replacing the whole layer with a handful of hand-written concrete types plus a few casts. How would you decide where clever type-level programming earns its keep and where it should be simplified, and what would you change without throwing the type safety away?
Your team's shared types package has grown a layer of deeply nested conditional types with several infer positions each. They are correct and well-tested, but a compile error inside one of them prints a 40-line expanded type that nobody can read, IDE hovers show the unexpanded alias instead of the resolved shape, and two developers have already shipped bugs by misreading which branch fires. A junior on the team proposes replacing the whole layer with a handful of hand-written concrete types plus a few casts. How would you decide where clever type-level programming earns its keep and where it should be simplified, and what would you change without throwing the type safety away?
Judge each type by whether it removes a real class of bug: a clever type earns its keep at a widely-used boundary, not in a leaf. Break the monoliths into small named aliases so errors and hovers report a meaningful name, add type-level tests pinning each branch, and keep the recursion shallow. Reject the casts — they trade a readability problem for a soundness one.
Common mistakes
- ✗Treating unreadable types as a tooling problem rather than a design signal
- ✗Swapping type-level logic for casts, trading readability for lost soundness
- ✗Judging a clever type by elegance instead of by the bugs it actually prevents
Follow-up questions
- →What does a type-level test suite actually pin down that a runtime test cannot?
- →How does naming an intermediate alias change what a compile error prints?
SeniorDesignRareYou own a product whose translation catalogue is a deeply nested object literal — sections, subsections, and message keys, several hundred leaves in total, authored as a single as const module. Today the lookup helper takes a string path such as 'checkout.errors.expiredCard', so a typo compiles cleanly and surfaces as a missing translation in production; renaming a section silently breaks every call site. The team wants the compiler to reject an unknown path, autocomplete the valid ones, and infer which interpolation arguments each message needs. Explain the type-level design you would build and the limits you would expect to hit.
You own a product whose translation catalogue is a deeply nested object literal — sections, subsections, and message keys, several hundred leaves in total, authored as a single as const module. Today the lookup helper takes a string path such as 'checkout.errors.expiredCard', so a typo compiles cleanly and surfaces as a missing translation in production; renaming a section silently breaks every call site. The team wants the compiler to reject an unknown path, autocomplete the valid ones, and infer which interpolation arguments each message needs. Explain the type-level design you would build and the limits you would expect to hit.
Keep the catalogue as const so the literals survive, then derive the key union with a recursive type that walks the object and builds dotted paths with a template literal type. An indexed access maps a path back to its message literal, and a second recursion parses that literal's placeholders into an argument object. Expect instantiation-depth and union-size limits to bite — bound the nesting, split large catalogues.
Common mistakes
- ✗Expecting
keyofto descend into nested objects and produce dotted paths - ✗Assuming a type cannot walk a nested object, so codegen is the only option
- ✗Ignoring the instantiation-depth and union-size limits a large catalogue will hit
Follow-up questions
- →Why must the catalogue stay
as constfor any of this to work? - →How would you split the catalogue once the key union outgrows the compiler's limits?
SeniorTheoryRareWhy can't a type parameter itself be generic, and what do libraries do instead?
Why can't a type parameter itself be generic, and what do libraries do instead?
TypeScript has no higher-kinded types: a type parameter always stands for a concrete type, never an unapplied type constructor, so you cannot write F<_> and later apply it as F<string>. Abstracting over "any container" directly is impossible. Libraries emulate it by defunctionalisation — register each constructor under a string key in an interface, pass the key, apply it via an indexed access.
Common mistakes
- ✗Assuming
infercan capture an unapplied type constructor, not just its argument - ✗Thinking a type alias lifts the restriction that an interface has
- ✗Reading the registry-plus-key pattern as boilerplate rather than the actual workaround
Follow-up questions
- →How does an interface keyed by a string tag let you apply a registered constructor?
- →What does declaration merging contribute to extending such a registry from user code?