Topic 17.2
Interview Questions: Advanced Types
In one line
The harder questions: generics and inference, conditional and mapped types, the utility types built by hand, and variance. Every answer is backed by an example the compiler checks.
Think of it like this
A chef's knife skills test. Nobody asks you to describe a knife. They hand you an onion and watch. Advanced type questions are the same: 'implement Partial', 'why does this array assignment crash?'. The examiner watches how you reason: what you try, what the compiler says, how you fix it. This topic gives you the working method and the checked proof.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Type parameter
- A placeholder like
Tthat the caller fills in, written in angle brackets. - Constraint
- A limit on a type parameter:
T extends { id: number }means only types with anidare allowed. - Inference
- The compiler working out the type arguments from the values you pass.
- Distributive
- A conditional type over a bare type parameter that is applied to each member of a union separately.
- infer
- A keyword inside a conditional type that captures part of the matched type in a new name.
- Mapped type
- A type that builds a new object type by looping over the keys of another type.
- Covariant
- Keeps the direction: a Dog list can be used as an Animal list.
- Contravariant
- Reverses the direction: a handler for any Animal can be used as a handler for Dogs.
- Bivariant
- Accepts both directions. TypeScript does this for methods, for compatibility.
Step by step
01Generics: keep the link between input and output
Start with first<T>(items: readonly T[]): T | undefined. Calling first([1, 2, 3]) gives number | undefined and first(['x', 1]) gives string | number | undefined: T was inferred as the union of what is in the array.
Then pluck<T, K extends keyof T>(obj: T, key: K): T[K]. pluck(user, 'name') is string, and pluck(user, 'nme') is a compile error listing the allowed keys. Say out loud why: K is limited to the keys of that object, and the return type reads the property type from the same object.
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}02Control inference with const and NoInfer
Without const, tuple('a', 2) infers [string, number]. With <const T extends readonly unknown[]> it infers readonly ['a', 2]. Use it when callers pass literals you want to keep (route tables, option lists).
pick(['red', 'blue'] as const, 'green') is an error because fallback: NoInfer<T> is not allowed to add candidates for T, so T stays 'red' | 'blue' and 'green' does not fit. Without NoInfer, the 'green' argument counts as one more candidate and T grows to 'blue' | 'green' | 'red', so the typo is accepted. (With a plain ['red', 'blue'] array instead of as const, T would simply be string.)
03Build the utility types, prove them with Equal
Implement MyPartial, MyRequired, MyExclude, MyReturnType and Getters and test each with Expect<Equal<Mine, Official>>. A passing line is silent. A failing line is TS2344: Type 'false' does not satisfy the constraint 'true'.
Explain each in one sentence: mapped type with ?; with -?; a distributive conditional type; a conditional type with infer; key remapping with a template literal type.
04Variance: arrays crash, handlers do not
const animals: Animal[] = dogs is allowed (arrays are covariant), then animals[0] = new Animal() puts a plain Animal into the dog list, and dogs[0]?.bark() throws at runtime. TypeScript chose convenience over soundness here.
Handler<T> = (value: T) => void is checked contravariantly: a handler for any Animal can be used for Dogs (fine), but a handler that needs a Dog cannot be used for Animals (error TS2322). A method declared as handle(a: Animal): void stays bivariant, so assigning a Dog-only function to it compiles and crashes when called with an Animal.
05How Equal works (a favourite follow-up)
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) is only true when A and B are exactly the same type. The compiler compares two deferred conditional types, and it treats them as equal only when their check and extends types are identical. A simpler A extends B check lets any, optional vs required and readonly differences pass.
You will not be asked to derive it, but you should be able to say 'it is the identity trick used by type-challenge style libraries', and use it correctly.
06Finish with a trade-off
End each answer with one sentence of judgement: 'I would use Partial from the library, not my own, and I would stop at DeepReadonly unless the data is really nested'. That is the senior signal.
Try it yourself
- 1
Break a helper
In the second example, change
MyExcludetoT extends U ? T : never. Predict whichExpectline fails, then confirm. This is the exact difference betweenExcludeandExtract. - 2
Remove the constraint
In the first example, change
pluck<T, K extends keyof T>topluck<T, K>. Two things happen:TS2536: Type 'K' cannot be used to index type 'T'appears twice (on the return typeT[K]and onobj[key]in the body), and the error onpluck(user, "nme")disappears, because anyKis now allowed. The hover onnamealso changes fromstringtounknown. That is the question 'why do we needK extends keyof T?' answered by the compiler. - 3
Fix the variance bug
In the third example, change
const animals: Animal[] = dogstoconst animals: readonly Animal[] = dogsand then try the assignmentanimals[0] = new Animal(). It isTS2542: Index signature in type 'readonly Animal[]' only permits reading.The crash disappears because the program no longer compiles: the readonly view lets you read dogs as animals (safe) but not write animals into the list (unsafe).
Code & diagrams
first keeps the link between input and output. pluck rejects a key that is not in the object. A const type parameter keeps literals. NoInfer stops green from widening the colour union.
Console
(nothing was printed)Expected console output
1 x Asha [ 'a', 2 ]Each helper is checked against the official type. G shows key remapping. The final Wrong line is an error on purpose: Exclude of string from string or number is number, not string.
Console
(nothing was printed)Expected console output
5Arrays are covariant (and unsound), function-type parameters are contravariant, method parameters stay bivariant. The program really crashes where TypeScript was lenient.
Console
(nothing was printed)Expected console output
dogs[0]?.bark is not a function
Uncaught TypeError: d.bark is not a functionBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Drop const from the type parameter
In the first example, change function tuple<const T extends readonly unknown[]> to function tuple<T extends readonly unknown[]>.
Break #2
Make a helper subtly wrong
In the second example, change MyExclude<T, U> = T extends U ? never : T; to T extends U ? T : never;.
Myth vs fact
Myth
Generics are only for libraries.
Fact
Any function that returns what it received, or links two parameters, benefits: first, groupBy, pluck, a typed cache. If you write the same function twice for two types, you want a generic.
Myth
Contravariance means the compiler is checking parameters backwards by mistake.
Fact
It is correct: a function that accepts every Animal is safe wherever a function that accepts Dogs is needed, not the other way round.
Myth
A passing Expect<Equal<>> means the type is good.
Fact
It means the type equals what you wrote. If the test is wrong the type is wrong too. Test the bad cases as well (a wrong key must be rejected).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
inferwith a constraint (infer U extends string, TypeScript 4.7) saves a nested conditional.ReturnType-style types need a parameter list that every function fits.unknown[]does not work ((a: string) => 1is not assignable to(...args: unknown[]) => any, so the result isnever), because parameters are checked contravariantly.never[]works and avoids writingany; the library itself usesany, which also works. - ▸
Distribution happens only for a naked type parameter. Wrapping it (
[T] extends [U]) turns it off, which is how you test fornever([T] extends [never]) and howEqual-style helpers avoid surprises. - ▸
TypeScript checks variance of type parameters by probing with marker types, and you can pin it with
in/outannotations (TypeScript 4.7), which can also speed up large generic types. The compiler still verifies them:interface Box<out T> { f: (x: T) => void }isTS2636: Type 'Box<sub-T>' is not assignable to type 'Box<super-T>' as implied by variance annotation. See Topic 7.7. - ▸
Method bivariance exists because many real DOM and library types (such as
Array<T>'s own methods) would otherwise be invariant and reject sensible code. It is a deliberate hole, covered in Topic 16.5.
Remember this
- 1
The problem this solves: advanced questions ask whether you can write one function or type that works for many types and still keeps the link between what goes in and what comes out. Writing it once per type repeats code, and
anythrows the link away. Generics (Topic 7.1) are type parameters: a function or type that works for many types while keeping the link between them.first<T>(items: readonly T[]): T | undefinedsays 'whatever the array holds, that is what comes out'. In an interview, always show the link (the sameTin and out), andK extends keyof T(Topic 7.3) as the standard way to constrain a key to a real property of an object. - 2
Inference (Topic 7.2) means the compiler picks
Tfrom the arguments, so callers rarely write<number>. Know the controls (Topic 7.6): the **consttype parameter** (TypeScript 5.0) keeps literal and readonly types (tuple('a', 2)becomesreadonly ['a', 2]), and **NoInfer<T>** (5.4) stops a parameter from being used as an inference source, so the first argument decidesTand a wrong second argument is an error. - 3
Conditional types (Topic 9.1) choose a type with
T extends U ? X : Y. They distribute (Topic 9.2) over a union whenTis a bare type parameter:Exclude<'a' | 'b', 'a'>checks each member separately.infer(Topic 9.3) names a part of the matched type (F extends (...args: never[]) => infer R ? R : neverisReturnType). Mapped types (Topic 8.4) loop over keys:{ [K in keyof T]?: T[K] }isPartial,-?removes optionality, andasrenames or filters keys (TypeScript 4.1). - 4
Variance (Topic 7.7) is the question 'if
Dogis assignable toAnimal(aDogvalue may be used wherever anAnimalis expected), what aboutBox<Dog>andBox<Animal>?'. Covariant (same direction): arrays, readonly data, return types. Contravariant (reversed): function parameters. WithstrictFunctionTypes(part ofstrict), a function-type parameter is checked contravariantly, but method syntax stays bivariant for compatibility, and mutable arrays are covariant even though that is unsound. This is the favourite 'why does TypeScript allow this?' question. - 5
The test helper. To prove a type, interviewers like
Expect<Equal<A, B>>.Equalcompares two types using two generic functions, which the checker treats as identical only when the types are identical (soanyandunknowndo not slip through the way they do withA extends B).Expect<T extends true>is an error whenTisfalse. The second example below uses both to check the utility types you build yourself (the utility types themselves are Topics 10.1 to 10.3). - 6
A good senior answer always ends with a trade-off: clever types cost compile time and make error messages harder to read, so use the simplest type that rejects the bugs you care about (Topic 10.6).
Explain it without notes
What are generics and why are they better than any?
How does type inference work for a generic call? When do you write the type argument yourself?
What is K extends keyof T for? Write pluck and explain the return type.
Explain conditional types, infer and distribution. How do you turn distribution off?
Implement Partial, Pick, Exclude and ReturnType from scratch.
What are covariance and contravariance? Why is const animals: Animal[] = dogs allowed, and is that a bug?
What does strictFunctionTypes do, and why are methods different?
How does the Equal type helper work, and how do you test a type in a codebase?
Practice
Write MyOmit<T, K extends keyof T> using Pick and Exclude, then test it with Expect<Equal<>>.
Write a generic groupBy so groupBy(users, 'role') returns Record<string, User[]> and a wrong key is an error.
Make the handler in this code safe by using a property instead of method syntax, and say what changes.
``ts
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
interface Listener {
handle(a: Animal): void;
}
const l: Listener = { handle: (d: Dog) => d.bark() };
Trade-offs
- ↔
Writing your own utility type shows skill in an interview but in real code the standard library version is better: documented, tested, and familiar to the next reader.
- ↔
Strict variance (
readonly, property-style handlers) catches real bugs, and costs you some convenience and an occasional extra annotation. Turn it on and let the compiler complain once, not a user.
Done when you can
I can write a generic function and explain where
Tis inferred from.I can use
K extends keyof TwithT[K]and explain why it is needed.I can use
consttype parameters andNoInferand say what each prevents.I can implement
Partial,Pick,ExcludeandReturnTypeand prove them withExpect<Equal<>>.I can explain covariance, contravariance and why arrays and methods are unsound.
I can explain how
Equalworks at a high level.