Ch. 2 · TypeScript

TypeScript Generics, Explained Through Interview Questions

Ten generics questions, from constraints and keyof to infer, distributive conditional types and variance, each with a short answer and code you can reproduce.

~6 min readintermediate

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 exist
TypeScript

If 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; }'
TypeScript

Without the constraint, a.length fails with “Property ‘length’ does not exist on type ‘T’”.

Note

In a constraint, extends means “is assignable to”, not class inheritance. string satisfies { 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 | number
TypeScript

The 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];
TypeScript

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'
TypeScript

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) on pair<A, B> fails with “Expected 2 type arguments, but got 1”. If the remaining parameters have defaults, the defaults are used, not inference.
  • A const type parameter (TypeScript 5.0) infers literal types, as if the caller had written as 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"
TypeScript

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">; // 42
TypeScript

This 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>; // true
TypeScript

Distribution 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 returns never. 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 }
TypeScript

Gotcha

Parameters of methods written with method syntax (handle(value: T): void) are checked bivariantly, even under strict. Arrays are covariant too: const animals: Animal[] = dogs compiles 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): void gains nothing over value: unknown. A type parameter earns its place by relating two or more positions.
  • Return-only generics. function parse<T>(json: string): T is a type assertion in disguise: the caller picks T and nothing checks it. Return unknown and 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
}
TypeScript

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

More in TypeScript

read ✓TypeScript · easy

type vs interface: When It Actually Matters

Declaration merging, extends vs intersections, what only type aliases can express, and the few differences that change behavior, with a clear rule of thumb.

~5 min readread →
read ✓TypeScript · hard

Build TypeScript's Utility Types From Scratch

Reimplement Partial, Pick, Omit, Exclude, ReturnType, Awaited and friends with mapped and conditional types, plus modifiers, key remapping and template literals.

~6 min readread →
read ✓System Design · hard

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readread →
esc