Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~29 min· topic 2 of 5

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 T that the caller fills in, written in angle brackets.
Constraint
A limit on a type parameter: T extends { id: number } means only types with an id are 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.

pluck.tswhole filets
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. 1

    Break a helper

    In the second example, change MyExclude to T extends U ? T : never. Predict which Expect line fails, then confirm. This is the exact difference between Exclude and Extract.

  2. 2

    Remove the constraint

    In the first example, change pluck<T, K extends keyof T> to pluck<T, K>. Two things happen: TS2536: Type 'K' cannot be used to index type 'T' appears twice (on the return type T[K] and on obj[key] in the body), and the error on pluck(user, "nme") disappears, because any K is now allowed. The hover on name also changes from string to unknown. That is the question 'why do we need K extends keyof T?' answered by the compiler.

  3. 3

    Fix the variance bug

    In the third example, change const animals: Animal[] = dogs to const animals: readonly Animal[] = dogs and then try the assignment animals[0] = new Animal(). It is TS2542: 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

Generics, constraints and inference control

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 ]
Utility types built by hand and proved with Equal

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

5
Variance: arrays, handlers and methods

Arrays 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 function

Break 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[]>.

terminal
$ Types panel
── what you'll see ──
index.ts(20,7): const t: [string, number]

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;.

terminal
$ Types panel
── what you'll see ──
index.ts(18,10): error TS2344: Type 'false' does not satisfy the constraint 'true'.

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.

  • ▸

    infer with 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) => 1 is not assignable to (...args: unknown[]) => any, so the result is never), because parameters are checked contravariantly. never[] works and avoids writing any; the library itself uses any, which also works.

  • ▸

    Distribution happens only for a naked type parameter. Wrapping it ([T] extends [U]) turns it off, which is how you test for never ([T] extends [never]) and how Equal-style helpers avoid surprises.

  • ▸

    TypeScript checks variance of type parameters by probing with marker types, and you can pin it with in / out annotations (TypeScript 4.7), which can also speed up large generic types. The compiler still verifies them: interface Box<out T> { f: (x: T) => void } is TS2636: 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. 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 any throws 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 | undefined says 'whatever the array holds, that is what comes out'. In an interview, always show the link (the same T in and out), and K extends keyof T (Topic 7.3) as the standard way to constrain a key to a real property of an object.

  2. 2

    Inference (Topic 7.2) means the compiler picks T from the arguments, so callers rarely write <number>. Know the controls (Topic 7.6): the **const type parameter** (TypeScript 5.0) keeps literal and readonly types (tuple('a', 2) becomes readonly ['a', 2]), and **NoInfer<T>** (5.4) stops a parameter from being used as an inference source, so the first argument decides T and a wrong second argument is an error.

  3. 3

    Conditional types (Topic 9.1) choose a type with T extends U ? X : Y. They distribute (Topic 9.2) over a union when T is 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 : never is ReturnType). Mapped types (Topic 8.4) loop over keys: { [K in keyof T]?: T[K] } is Partial, -? removes optionality, and as renames or filters keys (TypeScript 4.1).

  4. 4

    Variance (Topic 7.7) is the question 'if Dog is assignable to Animal (a Dog value may be used wherever an Animal is expected), what about Box<Dog> and Box<Animal>?'. Covariant (same direction): arrays, readonly data, return types. Contravariant (reversed): function parameters. With strictFunctionTypes (part of strict), 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. 5

    The test helper. To prove a type, interviewers like Expect<Equal<A, B>>. Equal compares two types using two generic functions, which the checker treats as identical only when the types are identical (so any and unknown do not slip through the way they do with A extends B). Expect<T extends true> is an error when T is false. 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. 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

01

What are generics and why are they better than any?

02

How does type inference work for a generic call? When do you write the type argument yourself?

03

What is K extends keyof T for? Write pluck and explain the return type.

04

Explain conditional types, infer and distribution. How do you turn distribution off?

05

Implement Partial, Pick, Exclude and ReturnType from scratch.

06

What are covariance and contravariance? Why is const animals: Animal[] = dogs allowed, and is that a bug?

07

What does strictFunctionTypes do, and why are methods different?

08

How does the Equal type helper work, and how do you test a type in a codebase?

Practice

01

Write MyOmit<T, K extends keyof T> using Pick and Exclude, then test it with Expect<Equal<>>.

02

Write a generic groupBy so groupBy(users, 'role') returns Record<string, User[]> and a wrong key is an error.

03

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 T is inferred from.

  • I can use K extends keyof T with T[K] and explain why it is needed.

  • I can use const type parameters and NoInfer and say what each prevents.

  • I can implement Partial, Pick, Exclude and ReturnType and prove them with Expect<Equal<>>.

  • I can explain covariance, contravariance and why arrays and methods are unsound.

  • I can explain how Equal works at a high level.