Generics are where TypeScript interviews stop being about syntax and start being about reasoning. Nobody is checking whether you can type <T>. The interviewer wants to know whether you understand what a type parameter is (a variable for types, filled in per use) and how the compiler decides what goes into it. The ten questions below cover the ground that usually comes up, each with a short answer you can say out loud and a snippet small enough for a whiteboard.
The basics
1. Why use generics instead of any?
any switches type checking off, so the link between what goes in and what comes out is lost. A type parameter captures the caller’s type and carries it through to the result.
function identityAny(value: any): any {
return value;
}
const a = identityAny({ x: 1 });
a.z.toFixed(); // compiles, crashes at runtime
function identity<T>(value: T): T {
return value;
}
const point = identity({ x: 1, y: 2 }); // { x: number; y: number }
point.x.toFixed(1); // OK
point.z; // ❌ Property 'z' does not existIf a function doesn’t need to relate types at all, the safe alternative to any is unknown: it accepts anything but makes you narrow before use.
2. How do you constrain a type parameter?
With extends. An unconstrained T could be anything, so inside the function you can only do what’s valid for every type. A constraint says “T can be any type that has at least this shape” and unlocks those members.
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest("abc", "de"); // T = string
longest([1, 2], [3]); // T = number[]
longest(10, 20); // ❌ 'number' is not assignable to '{ length: number; }'Without the constraint, a.length fails with “Property ‘length’ does not exist on type ‘T’”.
Note
In a constraint,
extendsmeans “is assignable to”, not class inheritance.stringsatisfies{ length: number }without extending anything.
Keys, lookups and defaults
3. How do keyof and indexed access work together?
keyof T is the union of T’s property names, and T[K] is the type of the property at K. Constraining K extends keyof T ties the key argument to the object, and returning T[K] gives the exact property type.
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ada", age: 36 };
const userName = getProp(user, "name"); // string
const userAge = getProp(user, "age"); // number
getProp(user, "email"); // ❌ "email" is not a key of user
type UserValue = (typeof user)[keyof typeof user]; // string | numberThe follow-up is usually “why a second type parameter?” If you write key: keyof T instead, the return type becomes T[keyof T], which is string | number for every call. The separate K captures which key was passed.
4. What are default type parameters for?
They work like default function parameters: when a type argument isn’t passed and can’t be inferred, the default is used instead of unknown. You see them a lot in library types.
interface ApiResponse<TData = unknown, TError = Error> {
data?: TData;
error?: TError;
}
const loose: ApiResponse = {}; // ApiResponse<unknown, Error>
const users: ApiResponse<string[]> = { data: ["ada"] }; // TError = Error
// ❌ Required type parameters may not follow optional type parameters.
type Pair<A = string, B> = [A, B];A default must also satisfy the constraint, so <T extends object = number> is an error.
Where the type parameter lives
5. Generic functions vs generic interfaces
It’s about when the type is chosen. On a generic function signature, T is picked fresh at every call. On a generic interface, type alias or class, T is picked once, when you write Mapper<string> or new Stack<number>(), and every member shares it.
type IdentityFn = <T>(value: T) => T; // generic function
interface Mapper<T> { (value: T): T } // generic interface
const id: IdentityFn = (v) => v;
id("a"); // T chosen for this call...
id(1); // ...and chosen again here
const upper: Mapper<string> = (v) => v.toUpperCase(); // T fixed up front
class Stack<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const stack = new Stack<number>();
stack.push("two"); // ❌ 'string' is not assignable to 'number'A class’s type parameter belongs to instances, so static members can’t use it: “Static members cannot reference class type parameters.”
6. How does inference work, and can you steer it?
TypeScript matches the arguments against the parameter types and picks the best candidate for each type parameter. Three details are worth knowing:
- Explicit type arguments are all-or-nothing.
pair<string>("a", 1)onpair<A, B>fails with “Expected 2 type arguments, but got 1”. If the remaining parameters have defaults, the defaults are used, not inference. - A
consttype parameter (TypeScript 5.0) infers literal types, as if the caller had writtenas const. NoInfer<T>(TypeScript 5.4) stops a position from contributing to inference.
function routes<const T extends readonly string[]>(paths: T): T {
return paths;
}
const r = routes(["/home", "/about"]); // readonly ["/home", "/about"]
// without `const`, T would be string[]
function createMachine<S extends string>(states: S[], initial: NoInfer<S>) {}
createMachine(["idle", "loading"], "idle"); // OK
createMachine(["idle", "loading"], "done"); // ❌ not "idle" | "loading"Without NoInfer, "done" would become another candidate for S and the call would compile.
Conditional types
7. What does infer do?
Inside the extends clause of a conditional type, infer X declares a type variable that TypeScript fills in by pattern matching. If the match succeeds, the true branch runs with X bound; otherwise you get the false branch.
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type E1 = ElementOf<string[]>; // string
type E2 = ElementOf<readonly [1, "a"]>; // 1 | "a"
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;
type U1 = UnwrapPromise<Promise<number>>; // number
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;
type F1 = FirstArg<(id: number, name: string) => void>; // number
// `infer X extends C` constrains what can be inferred
type ToNumber<S> = S extends `${infer N extends number}` ? N : never;
type N1 = ToNumber<"42">; // 42This is exactly how built-ins like ReturnType and Parameters are written; see Build TypeScript’s Utility Types From Scratch.
8. What is a distributive conditional type?
When the checked type is a naked type parameter and it’s instantiated with a union, the conditional runs once per member and the results are unioned back together.
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]
// Wrap both sides in a tuple to switch distribution off
type ToArrayAll<T> = [T] extends [unknown] ? T[] : never;
type B = ToArrayAll<string | number>; // (string | number)[]
type IsNever<T> = T extends never ? true : false;
type X = IsNever<never>; // never (!)
type IsNeverFixed<T> = [T] extends [never] ? true : false;
type Y = IsNeverFixed<never>; // trueDistribution is why Exclude<T, U> works, and never is the trap: it’s the empty union, so distributing over it produces zero results, which is never.
Interview tip
If you’re asked to write
IsNever, reach for[T] extends [never]and explain why the naked version returnsnever. That one sentence shows you understand distribution.
Variance and common mistakes
9. What are covariance and contravariance?
Variance describes how subtyping of T carries over to a generic type. If Dog extends Animal, output positions are covariant (() => Dog is assignable to () => Animal) and input positions are contravariant: a handler for any Animal can stand in for a Dog handler, but not the other way round. That check needs strictFunctionTypes, which strict turns on.
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
type Handler<T> = (value: T) => void;
const handleAnimal: Handler<Animal> = (a) => console.log(a.name);
const handleDog: Handler<Dog> = handleAnimal; // OK
const oops: Handler<Animal> = handleDog; // ❌ Property 'bark' is missing
// Optional variance annotations, checked by the compiler
interface Producer<out T> { get(): T }
interface Consumer<in T> { accept: (value: T) => void }Gotcha
Parameters of methods written with method syntax (
handle(value: T): void) are checked bivariantly, even understrict. Arrays are covariant too:const animals: Animal[] = dogscompiles and lets you push a cat into a list of dogs.
10. What mistakes do you see with generics?
- A type parameter used once.
function log<T>(value: T): voidgains nothing overvalue: unknown. A type parameter earns its place by relating two or more positions. - Return-only generics.
function parse<T>(json: string): Tis a type assertion in disguise: the caller picksTand nothing checks it. Returnunknownand validate. - Returning a concrete value as
T. The constraint is a lower bound, not the exact type:
function withDefaults<T extends { retries: number }>(options: T): T {
return { retries: 3 };
// ❌ '{ retries: number; }' is assignable to the constraint of type 'T',
// but 'T' could be instantiated with a different subtype of constraint
}A caller could pass { retries: 1, verbose: true } and expect verbose back. Reaching for as T to silence this just moves the bug to the call site.
The interview answer
“A generic is a type parameter: a variable for types that TypeScript fills in per call, usually by inference from the arguments. I use generics when I need to preserve a relationship, such as the output having the same type as the input, or a return type depending on which key was passed, which is K extends keyof T and T[K]. Constraints with extends say what shape I need, and conditional types with infer let me extract types, keeping in mind that they distribute over unions unless I wrap them in a tuple.
I also know when not to use them: if a type parameter appears only once, or only in the return type, it’s really unknown or an unchecked cast, so I’d rewrite the signature.”