TypeScript NotesRohit’s interview study guide
Chapter 08

Generics

Writing code that works for many types without losing type safety — functions, interfaces, classes, constraints and defaults.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Generic functions

Generics in TypeScript let you write reusable code that works with many types while keeping strong type safety. A type parameter like T is a parameter for types: a function's ordinary parameters are blanks filled with values at each call, and its type parameters are blanks filled with types at each call. The checker fills them in for you (inference) or you write them explicitly (identity<number>(5)).

Why they exist: without generics you have two bad choices for a function that should "work with anything". You can write it once with any, which throws away everything the compiler knew about the argument. Or you can write one copy per type (identityString, identityNumber, …) or a stack of overloads. A generic says something more precise than either: "whatever type comes in, the same type comes out". The point of T is to connect types — between a parameter and the return type, or between two parameters.

A good mental model is a funnel with a label. The value goes in, the checker writes its type on a label called T, and every other place that mentions T in the signature gets the same label. Nothing exists at runtime: generics are erased like every other type, so identity<number>(5) compiles to plain identity(5).

TypeScript
function identityAny(arg: any): any {
  return arg;
}

const a = identityAny("Hello"); // const a: any
a.toFixed(2); // no compile error — TypeError at runtime

function identity<T>(arg: T): T {
  return arg;
}

const b = identity("Hello"); // const b: "Hello"
b.toUpperCase(); // fine
b.toFixed(2); // ❌ Property 'toFixed' does not exist on type '"Hello"'. Did you mean 'fixed'?
What’s happening
  1. identityAny("Hello") returns any. The string went in, but the signature has no way to say "the same thing comes out", so the type information is gone: a is any.
  2. On an any, every property access is allowed, so a.toFixed(2) compiles. At runtime "Hello".toFixed is undefined, and calling it throws TypeError: a.toFixed is not a function.
  3. identity<T> declares a type parameter T and uses it twice: for the parameter (arg: T) and for the return type (: T). That second use is what makes it useful — it links output to input.
  4. At the call identity("Hello"), the checker matches the argument type "Hello" against the parameter type T and concludes T = "Hello". It substitutes that into the return type, so the call returns "Hello", and b is "Hello" (a const keeps the literal type).
  5. b.toUpperCase() is a string method, so it's fine. b.toFixed(2) is rejected with TS2551, because "Hello" is a string literal type and strings have no toFixed (the "Did you mean 'fixed'?" is the checker suggesting the old String.prototype.fixed HTML method — ignore it). Same runtime code as identityAny — the only difference is that the compiler kept track.
AdvancedExplicit vs inferred type arguments

The doc's example shows the two ways to supply T:

TypeScript
function identity<T>(arg: T): T {
  return arg;
}

let result1 = identity<number>(5); // explicit: T = number → let result1: number
let result2 = identity("Hello"); // inferred: T = "Hello" → let result2: string
const result3 = identity("Hello"); // inferred: T = "Hello" → const result3: "Hello"

identity<number>("5"); // ❌ Argument of type 'string' is not assignable to parameter of type 'number'.
What’s happening
  1. identity<number>(5) supplies the type argument explicitly. The checker doesn't infer anything — it substitutes T = number everywhere, so the signature becomes (arg: number) => number, checks 5 against number (fine) and returns number.
  2. identity("Hello") has no type argument, so the checker infers T from the argument: T = "Hello", a string literal type, and the call's type is "Hello".
  3. result2 is declared with let. A literal type that came from a literal expression is a widening literal: assigning it to a mutable let widens it to string, because you could reassign result2 = "Bye" later. That's why result2 is string.
  4. result3 is a const, which can never be reassigned, so it keeps the literal: const result3: "Hello". This is the same let/const widening you get without generics (see Primitive and literal types).
  5. identity<number>("5") fixes T = number first, and only then checks the argument. "5" is a string, so you get TS2345 — explicit type arguments turn a generic into an ordinary, non-generic signature for that call.
AdvancedWhen a generic function pays off

When to use a generic function: whenever you'd otherwise copy the same function for each type, and the types of the inputs and the output are related. The doc's second example takes an array and returns one of its elements:

TypeScript
function getFirstElement<T>(arr: T[]): T {
  return arr[0];
}

const n = getFirstElement([10, 20, 30]); // T = number → const n: number
const s = getFirstElement(["a", "b"]); // T = string → const s: string

const nothing = getFirstElement<number>([]); // const nothing: number … but it's undefined
nothing.toFixed(2); // compiles, then throws at runtime
What’s happening
  1. The parameter type is T[]. For [10, 20, 30] the argument type is number[], so the checker lines up T[] with number[] and reads off T = number. The return type T becomes number.
  2. For ["a", "b"] the array literal's type is string[] (array elements are widened when the array is created), so T = string and s is string.
  3. getFirstElement<number>([]) is where the signature lies. arr[0] on an empty array is undefined at runtime, but the return type says T, which is number.
  4. Why does the body compile? By default, indexing an array with a number gives the element type (T) without adding undefined — TypeScript trusts that the index is in range. So return arr[0] is accepted as returning T.
  5. The result: nothing is typed number, nothing.toFixed(2) compiles, and at runtime it throws TypeError: Cannot read properties of undefined (reading 'toFixed').
AdvancedAn honest first returning T | undefined

The honest version says what really comes back:

TypeScript
function first<T>(arr: readonly T[]): T | undefined {
  return arr[0];
}

const maybe = first([10, 20, 30]); // const maybe: number | undefined
maybe.toFixed(2); // ❌ 'maybe' is possibly 'undefined'.
maybe?.toFixed(2); // fine
What’s happening
  1. The return type is now T | undefined, so with T = number the call returns number | undefined.
  2. maybe.toFixed(2) fails with TS18048, forcing the caller to handle the empty case — which is exactly the case that crashed above.
  3. readonly T[] as the parameter type is a small extra: it promises the function won't mutate the array, and it lets callers pass both mutable arrays and readonly arrays or as const tuples.
  4. If you turn on noUncheckedIndexedAccess in tsconfig, the original return arr[0] stops compiling with "Type 'T | undefined' is not assignable to type 'T'", because every index access then includes undefined. That flag is the project-wide fix for this whole class of lie.
AdvancedGenerics instead of per-type overloads

Generics also beat overloads whenever the overloads would just be "the same signature, once per type" — see Generics are often better than overloads.

#How type arguments are inferred

When you call a generic function without writing <…>, the checker infers the type arguments. Roughly, it walks each argument whose parameter type mentions a type parameter, matches the two types structurally, and collects candidates: arg: T with "Hello" gives the candidate "Hello" for T; arr: T[] with number[] gives number; fn: (x: T) => U with a callback returning number gives number for U. Then it picks one type per parameter from its candidates and checks every argument against the result.

A few rules decide what you see on hover, and they come up in interviews because they explain the "why is this string and not "a"?" questions:

  • With several candidates, the checker doesn't automatically union them (except literals of the same primitive, like "a" and "b"). If one candidate is a supertype of the others it picks that one; otherwise it settles on one of them by internal rules and reports the arguments that don't fit.
  • Literal candidates widen unless something tells the checker to keep them: a constraint that mentions a primitive (T extends string), T appearing directly in the return type with a const variable receiving it, or a const type parameter.
  • Arguments are processed left to right, and callbacks whose parameters aren't annotated are processed after the others, so their parameters can be typed from what was already inferred.
TypeScript
function pair<T>(a: T, b: T): [T, T] {
  return [a, b];
}

const p1 = pair(1, 2); // T = number → const p1: [number, number]
pair(1, "x"); // ❌ Argument of type 'string' is not assignable to parameter of type 'number'.
const p2 = pair<string | number>(1, "x"); // const p2: [string | number, string | number]
What’s happening
  1. pair(1, 2): both arguments give candidates for T — 1 and 2, both widening number literals. They're combined and widened to number, so p1 is [number, number].
  2. pair(1, "x"): the candidates are number (from 1) and string (from "x"). Neither is a supertype of the other, and the checker deliberately does not invent string | number — usually two unrelated types in "the same T" is a bug.
  3. It settles on one candidate — here T = number — then checks each argument against number. "x" fails with TS2345. Notice the error is on the second argument even though the "real" problem is the mismatch between the two.
  4. If you really mean "either", say so: pair<string | number>(1, "x") fixes T up front, so no inference happens and both arguments are checked against the union.
AdvancedLeft-to-right inference with callbacks

Callbacks show the left-to-right order:

TypeScript
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}

const lengths = map(["a", "bb"], (s) => s.length); // const lengths: number[]
const upper = map([1, 2], (n) => n.toUpperCase()); // ❌ Property 'toUpperCase' does not exist on type 'number'.
What’s happening
  1. map(["a", "bb"], (s) => s.length): the arrow function's s has no annotation, so it's context-sensitive. The checker sets it aside and first infers from the other argument: string[] against T[] gives T = string.
  2. Now it types the callback using the parameter type (item: T) => U with T = string, so s is string — this is contextual typing driven by a type parameter.
  3. With s: string, the body s.length is number, which becomes the candidate for U. So U = number and the return type U[] is number[].
  4. In the second call T = number is fixed from [1, 2] before the callback is checked, so n is number and n.toUpperCase() fails with TS2339. Inference used the array to catch a mistake inside the callback.
AdvancedKeeping literals: constraints and const type parameters
TypeScript
function plain<T>(value: T): T {
  return value;
}
function stringy<T extends string>(value: T): T {
  return value;
}

let a = plain("hi"); // let a: string
let b = stringy("hi"); // let b: "hi"

function routes<const T extends readonly unknown[]>(paths: T): T {
  return paths;
}
function routesPlain<T extends readonly unknown[]>(paths: T): T {
  return paths;
}

const r1 = routes(["/home", "/about"]); // const r1: readonly ["/home", "/about"]
const r2 = routesPlain(["/home", "/about"]); // const r2: string[]
What’s happening
  1. plain("hi") infers T = "hi", but it's a widening literal, so the let widens it to string — same as result2 above.
  2. stringy("hi") has the constraint T extends string. A constraint that mentions a primitive type is the checker's hint that you care about the exact literal, so it infers the non-widening "hi". let b stays "hi".
  3. routesPlain(["/home", "/about"]): the array literal is typed on its own as string[] (elements widen), and that's what T becomes.
  4. routes declares const T (TypeScript 5.0+). The const modifier makes the checker infer as if the argument had been written with as const: the array becomes the tuple readonly ["/home", "/about"], keeping each literal and the length.
  5. The constraint is readonly unknown[] on purpose. A const type parameter infers readonly arrays, and a readonly tuple isn't assignable to a mutable unknown[] constraint; since TypeScript 5.3 the checker falls back to a mutable tuple in that case, but writing readonly makes the intent clear. The callers don't have to write as const — see as const.
AdvancedBlocking inference with NoInfer

Sometimes a type parameter appears in two places and you want only one of them to decide what it is. NoInfer<T> (TypeScript 5.4) marks a position as "check against T, but don't use me as a candidate".

TypeScript
function createLight<C extends string>(colors: C[], initial: C) {}
createLight(["red", "green"], "blue"); // compiles — C = "red" | "green" | "blue"

function createSafeLight<C extends string>(colors: C[], initial: NoInfer<C>) {}
createSafeLight(["red", "green"], "blue"); // ❌ Argument of type '"blue"' is not assignable to parameter of type '"green" | "red"'.
createSafeLight(["red", "green"], "red"); // fine
What’s happening
  1. In createLight, both parameters mention C, so both arguments are candidates: "red" and "green" from the array, "blue" from initial. Because of the string constraint these stay literals, and the checker unions them: C = "red" | "green" | "blue".
  2. Every argument fits that union, so the call compiles — and the bug (an initial colour that isn't in the list) slips through. initial helped define the set it was supposed to be checked against.
  3. In createSafeLight, initial: NoInfer<C> means "don't collect candidates here". The only candidates come from the array, so C = "red" | "green".
  4. Only then is "blue" checked against C, and it fails with TS2345 (the compiler prints the union as "green" | "red"; order inside a union doesn't matter). "red" passes.

#Generic interfaces and type aliases

Types can take parameters too. Array<T>, Promise<T>, Map<K, V> and Record<K, V> are all generic types from the standard library; you write your own the same way, with both interfaces and type aliases. Think of a generic type as a type-level function: ApiResponse<User> "calls" ApiResponse with User and gets back a concrete object type.

One difference from generic functions: when you use a generic type in an annotation, there's nothing to infer from, so you must pass the type arguments yourself (unless the parameter has a default).

TypeScript
interface ApiResponse<T> {
  data: T;
  status: number;
  error?: string;
}

type Pair<A, B> = [A, B];
type Nullable<T> = T | null;

interface User {
  id: number;
  name: string;
}

const res: ApiResponse<User[]> = {
  data: [{ id: 1, name: "Ann" }],
  status: 200,
};
const entry: Pair<string, number> = ["age", 30];
let selected: Nullable<User> = null;

const bad: ApiResponse = { data: 1, status: 200 }; // ❌ Generic type 'ApiResponse<T>' requires 1 type argument(s).
What’s happening
  1. ApiResponse<User[]> substitutes T = User[] into the interface body, producing { data: User[]; status: number; error?: string }. The object literal is then checked against that, so a typo in name or a missing status would be caught.
  2. Pair<string, number> substitutes into the alias body and gives the tuple [string, number]. Type aliases can be generic over anything an alias can describe — tuples, unions, functions — which interfaces can't (an interface is always an object shape).
  3. Nullable<User> becomes User | null, so selected can start as null and later hold a User.
  4. const bad: ApiResponse has no type argument and T has no default, so the checker refuses with TS2314. It never guesses T from the initialiser in a type annotation.
AdvancedWhere the type parameter lives

The position of <T> changes what a function type means. This is a classic follow-up question.

TypeScript
type Mapper<T, U> = (item: T) => U; // generic TYPE: you pick T and U when you use the type
type Identity = <T>(item: T) => T; // generic FUNCTION: each call picks its own T

const len: Mapper<string, number> = (s) => s.length;
len("abc"); // number
len(42); // ❌ Argument of type 'number' is not assignable to parameter of type 'string'.

const id: Identity = (x) => x;
const x1 = id(42); // const x1: 42
const x2 = id("abc"); // const x2: "abc"
What’s happening
  1. Mapper<T, U> puts the parameters on the alias. Writing Mapper<string, number> fixes them once and produces the plain function type (item: string) => number. The function stored in len is not generic.
  2. That's why len(42) fails: len only takes strings — TS2345.
  3. Identity puts <T> on the call signature. The alias itself takes no arguments; the function type it names is generic, so every call infers a fresh T.
  4. id(42) infers T = 42 and id("abc") infers T = "abc" — one function, a different T per call. The two consts keep the literals because T is the whole return type.
  5. Rule of thumb: put the type parameter on the alias/interface when the user of the type should choose it (a callback prop of a component); put it on the signature when each call should choose it (a utility function).

Generic interfaces are how you describe reusable contracts — a repository, a cache, a store:

TypeScript
interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | undefined>;
  save(item: T): Promise<void>;
}

interface Product {
  id: string;
  price: number;
}

class InMemoryRepo<T extends { id: string }> implements Repository<T> {
  private items = new Map<string, T>();
  async findById(id: string) {
    return this.items.get(id);
  }
  async save(item: T) {
    this.items.set(item.id, item);
  }
}

const products = new InMemoryRepo<Product>();
products.save({ id: "p1", price: 10 });
products.save({ id: "p2" }); // ❌ Property 'price' is missing in type '{ id: string; }' but required in type 'Product'.
What’s happening
  1. Repository<T extends { id: string }> is a generic interface with a constraint: any T you plug in must at least have a string id, because a repository looks things up by id.
  2. InMemoryRepo<T …> implements Repository<T> passes its own T through to the interface. implements only checks that the class matches Repository<T> — it adds nothing at runtime (see Implementing interfaces in classes).
  3. new Map<string, T>() uses a built-in generic class; this.items.get(id) returns T | undefined, which matches Promise<T | undefined> once async wraps it.
  4. new InMemoryRepo<Product>() fixes T = Product for that instance, so save now takes a Product.
  5. save({ id: "p2" }) is missing price and fails with TS2741 — the generic interface gave you one implementation with per-entity type safety.

#Generic classes

A generic class has type parameters on the class itself, and every instance gets its own T. This is what you want for containers — boxes, lists, stacks, queues, trees, caches — where the structure's logic is the same whatever it holds, but each instance holds one particular type.

The doc's example:

TypeScript
class Box<T> {
  contents: T;
  constructor(value: T) {
    this.contents = value;
  }
  getContents(): T {
    return this.contents;
  }
}

const stringBox = new Box<string>("Hello");
const numberBox = new Box<number>(123);
const inferred = new Box("Hello"); // const inferred: Box<string>

stringBox.getContents().toUpperCase(); // "HELLO"
numberBox.getContents().toFixed(1); // "123.0"
numberBox.contents = "oops"; // ❌ Type 'string' is not assignable to type 'number'.
What’s happening
  1. class Box<T> declares T for the whole class body: the field contents, the constructor parameter and the return type of getContents all mean "the type this particular box holds".
  2. new Box<string>("Hello") fixes T = string for that instance. Its type is Box<string>, so getContents() returns string and .toUpperCase() is allowed.
  3. new Box<number>(123) is a separate instance with T = number. The two boxes share one class (one constructor function at runtime) but have different types.
  4. new Box("Hello") infers T from the constructor argument. You get Box<string>, not Box<"Hello">: the literal widens because T doesn't appear directly as the result type — the result is Box<T>.
  5. numberBox.contents = "oops" fails with TS2322, because for this instance contents is number.
AdvancedA generic Stack and two pitfalls

A typical data structure, plus two rules that trip people up:

TypeScript
class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }
  pop(): T | undefined {
    return this.items.pop();
  }
  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }
  get size(): number {
    return this.items.length;
  }

  static fromArray<U>(items: U[]): Stack<U> {
    const s = new Stack<U>();
    items.forEach((i) => s.push(i));
    return s;
  }
}

const nums = new Stack<number>();
nums.push(1);
nums.push(2);
nums.pop(); // 2 (type: number | undefined)

const names = Stack.fromArray(["ann", "bob"]); // const names: Stack<string>
const mystery = new Stack(); // const mystery: Stack<unknown>
What’s happening
  1. items: T[] is private state typed by the class's T. push only accepts a T; pop and peek return T | undefined because the stack might be empty.
  2. new Stack<number>() fixes T = number; nums.push(1) and nums.push(2) add values, and nums.pop() returns 2 at runtime and number | undefined at compile time.
  3. static fromArray<U> declares its own type parameter. Static members belong to the class constructor, not to an instance, so they can't see the instance's T — writing static empty: T gives "TS2302: Static members cannot reference class type parameters."
  4. Stack.fromArray(["ann", "bob"]) infers U = string from the array, builds a Stack<string> and returns it.
  5. new Stack() has no type argument and no constructor argument to infer from, so T falls back to its constraint, which for an unconstrained parameter is unknown. mystery.push(anything) compiles and mystery.pop() is unknown — always pass <T> when nothing can be inferred.

#Generic constraints

An unconstrained T could be anything — number, null, a function — so inside the function the checker only lets you do what's valid for every type. A constraint (T extends Something) narrows the set of allowed type arguments, and in exchange you get to use Something's members inside the body. Read extends here as "must be assignable to", not as class inheritance.

The doc's example: a function that only works with things that have a length.

TypeScript
function noConstraint<T>(arg: T): number {
  return arg.length; // ❌ Property 'length' does not exist on type 'T'.
}

function logLength<T extends { length: number }>(arg: T): void {
  console.log(arg.length);
}

logLength([1, 2, 3]); // 3
logLength("Hello"); // 5
logLength({ length: 10, value: 3 }); // 10
logLength(123); // ❌ Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
What’s happening
  1. In noConstraint, T could be number, which has no length, so arg.length fails with TS2339 — the checker reasons about every possible T, not about the calls you happen to make.
  2. T extends { length: number } limits T to types that have a numeric length. Inside the body the checker now knows at least that much about arg, so arg.length is number.
  3. [1, 2, 3] infers T = number[]; arrays have length, so the constraint is satisfied → logs 3. "Hello" infers T = "Hello"; strings have length → logs 5.
  4. { length: 10, value: 3 } is fine too — the constraint is structural, and extra properties are allowed when they're part of T.
  5. logLength(123): number has no length property, so the argument fails the constraint with TS2345.
AdvancedWhy a generic beats a plain constraint type

Why not just write arg: { length: number } without a generic? Because then you can't give the caller back their exact type. With a generic you can:

TypeScript
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

const arr = longest([1, 2], [3]); // const arr: number[]
const str = longest("alice", "bob"); // const str: "alice" | "bob"
arr.push(4); // fine — it's still an array
longest([1, 2], "abc"); // ❌ Argument of type 'number[]' is not assignable to parameter of type '"abc"'.
What’s happening
  1. longest([1, 2], [3]): both arguments give the candidate number[], so T = number[], which satisfies the constraint. The return type is number[] — not { length: number } — so arr.push(4) is allowed.
  2. longest("alice", "bob"): the candidates are the literals "alice" and "bob"; both are strings, and the checker unions literal candidates of the same primitive, giving T = "alice" | "bob". A const keeps it.
  3. longest([1, 2], "abc"): the candidates number[] and "abc" are unrelated, so the checker has to pick one. Here it settles on T = "abc" (it does so whichever order the arguments come in) and then rejects the array with TS2345. The error lands on the argument that lost, which isn't always the one you think is wrong — but the real message is the same: one T means both arguments must be the same kind of thing.
  4. With a non-generic (a: { length: number }, b: { length: number }): { length: number }, all three calls would compile and every result would be just { length: number } — you'd lose push, toUpperCase and everything else.
AdvancedCompiler error: returning a new object as T

#Multiple constraints

A type parameter has one extends clause, but that clause can be any type — including an intersection. T extends A & B means "T must have everything from A and everything from B". There's no T extends A, B syntax (that would be read as a second type parameter), and T extends A | B means something else: T must be one or the other, so inside the body you can only use what they share.

TypeScript
interface HasId {
  id: string;
}
interface Timestamped {
  createdAt: Date;
}

function describe<T extends HasId & Timestamped>(entity: T): string {
  return `${entity.id} created ${entity.createdAt.toISOString()}`;
}

const order = { id: "o-1", createdAt: new Date(0), total: 99 };
describe(order); // "o-1 created 1970-01-01T00:00:00.000Z"
describe({ id: "u-1" }); // ❌ Argument of type '{ id: string; }' is not assignable to parameter of type 'HasId & Timestamped'.
What’s happening
  1. HasId & Timestamped is the intersection type { id: string; createdAt: Date } — a value must have both properties.
  2. Inside describe, the checker knows entity has both, so entity.id and entity.createdAt.toISOString() are allowed.
  3. describe(order) infers T = { id: string; createdAt: Date; total: number }. It has both required properties (plus total, which is fine), so the call compiles and returns "o-1 created 1970-01-01T00:00:00.000Z".
  4. describe({ id: "u-1" }) has no createdAt, so it fails the intersection with TS2345 (the full message adds "Property 'createdAt' is missing in type '{ id: string; }' but required in type 'Timestamped'").
  5. Because T is generic, the function could return entity and keep total in the caller's type — the same reason as in Generic constraints.
AdvancedSeveral type parameters with constraints

"Multiple constraints" also means several type parameters, each with its own constraint, and constraints that refer to other type parameters:

TypeScript
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

const merged = merge({ name: "Ann" }, { age: 30 }); // const merged: { name: string; } & { age: number; }
merged.age; // number
merge({ name: "Ann" }, 5); // ❌ Argument of type 'number' is not assignable to parameter of type 'object'.

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

const ages = pluck([{ name: "Ann", age: 30 }], "age"); // const ages: number[]
What’s happening
  1. merge has two independent type parameters, each constrained to object (any non-primitive), so you can't spread a number into the result by accident — merge({…}, 5) fails with TS2345.
  2. For the valid call, T = { name: string } and U = { age: number }, and the declared return type T & U gives a value with both properties, so merged.age is number.
  3. In pluck, the constraint of K mentions T: K extends keyof T. The checker infers T from items first ({ name: string; age: number }), which makes keyof T equal "name" | "age", and then checks key against that.
  4. "age" is allowed, so K = "age", and the return type T[K][] resolves to number[]. This is the same pattern as getValue below, applied to an array.

#keyof constraints: a type-safe getValue

This is the most-asked generics question. The task: write getValue(obj, key) so that the key must be a real key of the object, and the return type is the type of that property — not a union of all of them, and not any.

It combines three pieces: keyof T (the union of T's keys), a constraint K extends keyof T (the key parameter must be one of them), and the indexed access type T[K] (the type of the property at key K). The doc's version:

TypeScript
interface Person {
  name: string;
  age: number;
}

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person: Person = { name: "John", age: 30 };

const name = getValue(person, "name"); // const name: string
const age = getValue(person, "age"); // const age: number
getValue(person, "height"); // ❌ Argument of type '"height"' is not assignable to parameter of type 'keyof Person'.
What’s happening
  1. getValue(person, "name"): the checker infers T from the first argument, T = Person. That fixes the constraint for K: keyof T is keyof Person, which is "name" | "age".
  2. From the second argument it infers K = "name" — the literal type, not string, because K's constraint (keyof Person) is made of literal types, so the literal is kept. It checks "name" against "name" | "age": fine.
  3. The return type T[K] becomes Person["name"], which the checker looks up in Person: string. So name is string.
  4. getValue(person, "age") runs the same steps with K = "age", so T[K] is Person["age"], which is number.
  5. getValue(person, "height"): "height" isn't in "name" | "age", so it fails the constraint with TS2345. Typos in keys are caught at compile time, and your editor autocompletes "name" and "age" in that position.
  6. Inside the body, obj[key] has the type T[K] — the checker can index a T with a K because K extends keyof T guarantees it's a valid key. That's why no assertion is needed.
AdvancedWhy a separate K type parameter

Why have a separate K at all? Compare a version that types the key directly as keyof T:

TypeScript
interface Person {
  name: string;
  age: number;
}

function getLoose<T>(obj: T, key: keyof T): T[keyof T] {
  return obj[key];
}

const person: Person = { name: "John", age: 30 };
const n = getLoose(person, "name"); // const n: string | number
n.toUpperCase(); // ❌ Property 'toUpperCase' does not exist on type 'string | number'.
What’s happening
  1. getLoose still rejects bad keys: key: keyof T is "name" | "age" once T = Person.
  2. But nothing remembers which key you passed. The parameter type is the whole union, so the call site's "name" is just checked against it and forgotten.
  3. The return type T[keyof T] is Person["name" | "age"], which is string | number — every value type (see [T[keyof T]](#getting-every-value-type-with-t-keyof-t)).
  4. So n is string | number and n.toUpperCase() fails with TS2339, even though you obviously asked for name. The extra type parameter K is what captures the specific key so that T[K] can look up the specific property.
What is the type of v?
TypeScript
interface Person {
  name: string;
  age: number;
}

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person: Person = { name: "John", age: 30 };
const key = Math.random() > 0.5 ? "name" : "age";
const v = getValue(person, key);
Show answer

v is string | number.

What’s happening
  1. key is a const initialised with a conditional expression whose branches are "name" and "age", so its type is the union "name" | "age" (a const keeps the literals).
  2. In the call, T = Person, and K is inferred from key: K = "name" | "age". That satisfies K extends keyof Person — the union is keyof Person.
  3. T[K] with a union key distributes over the union: Person["name" | "age"] is Person["name"] | Person["age"], which is string | number.
  4. This is correct, not a weakness: at compile time you don't know which key you'll get, so the value could be either. getValue gives the precise type when the key is a single literal and the honest union when it isn't.

#A generic setter

The doc's comment on this one: "look at 3rd argument, confused me first why we doing it". The setter is getValue's twin: given an object, a key and a value, set the property — but only accept a value of the right type for that key.

TypeScript
interface Person {
  name: string;
  age: number;
}

function setValue<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

const person: Person = { name: "John", age: 30 };

setValue(person, "age", 31); // fine
setValue(person, "name", "Jane"); // fine
setValue(person, "age", "31"); // ❌ Argument of type 'string' is not assignable to parameter of type 'number'.
setValue(person, "nmae", "Jane"); // ❌ Argument of type '"nmae"' is not assignable to parameter of type 'keyof Person'.
What’s happening
  1. setValue(person, "age", 31): T = Person from the first argument, K = "age" from the second (checked against "name" | "age").
  2. The third parameter's type is T[K], so after substitution it's Person["age"], which is number. Only then is 31 checked against it — fine. This is why the third argument is T[K]: it depends on the second argument. The value's type is chosen by the key.
  3. setValue(person, "name", "Jane"): K = "name", so the third parameter becomes Person["name"] = string, and "Jane" fits.
  4. setValue(person, "age", "31"): K = "age" makes the value parameter number, and the string "31" fails with TS2345. Without the T[K] link, this is the bug that turns age into a string at runtime.
  5. setValue(person, "nmae", …): the key fails its constraint first, with TS2345.
  6. Inside the body, obj[key] is T[K] and value is T[K], so the assignment type-checks without any casts.
AdvancedA setter that looks right but isn't

The version that looks right but isn't:

TypeScript
interface Person {
  name: string;
  age: number;
}

function setLoose<T>(obj: T, key: keyof T, value: T[keyof T]): void {
  obj[key] = value;
}

const person: Person = { name: "John", age: 30 };
setLoose(person, "age", "thirty"); // compiles — person.age is now a string at runtime
What’s happening
  1. T = Person, so key: keyof T becomes "name" | "age" and value: T[keyof T] becomes Person["name" | "age"] = string | number.
  2. "age" is a valid key and "thirty" is a string, which is in string | number. Both arguments pass independently — nothing connects the key to the value.
  3. At runtime person.age becomes "thirty", while its type still says number. A later person.age.toFixed() throws.
  4. The generic K is the fix: it gives the key its own name so that the value's type can refer to it.
AdvancedEdge case: union keys in a setter
Which calls compile?
TypeScript
interface Settings {
  theme: "light" | "dark";
  fontSize: number;
}

function setValue<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

const s: Settings = { theme: "light", fontSize: 14 };
setValue(s, "theme", "dark"); // A
setValue(s, "theme", "blue"); // ❌ B
setValue(s, "fontSize", 16); // C
Show answer

A and C compile; B fails with "Argument of type '"blue"' is not assignable to parameter of type '"dark" | "light"'." (the compiler sorts the union members when it prints them).

What’s happening
  1. A: T = Settings, K = "theme", so the value parameter is Settings["theme"] = "light" | "dark". The argument "dark" is checked against that union, so it's kept as the literal "dark" — fine.
  2. B: same K, same value type. "blue" isn't one of the two literals, so it fails with TS2345. The setter enforces the literal union, not just "some string".
  3. C: K = "fontSize", so the value type is number, and 16 fits.

#Default type parameters

Added

A type parameter can have a default: <T = string>. The default is used when the type argument is neither written nor inferable — mostly for generic types in annotations (where nothing is ever inferred) and for generic functions called without an argument that mentions T. It works like a default value for a function parameter, at the type level.

The rules: parameters with defaults must come after the required ones; a default must satisfy the parameter's own constraint; and inference still wins over the default whenever there's something to infer from.

TypeScript
interface ApiResponse<T = unknown> {
  data: T;
  status: number;
}

const raw: ApiResponse = { data: "anything", status: 200 }; // T = unknown
const typed: ApiResponse<number[]> = { data: [1, 2], status: 200 };

function createState<T = string>(initial?: T) {
  return { value: initial };
}

const s1 = createState(); // const s1: { value: string | undefined; }
const s2 = createState(5); // const s2: { value: number | undefined; }

interface Bad<T = string, U> { // ❌ Required type parameters may not follow optional type parameters.
  a: T;
  b: U;
}
interface AlsoBad<T extends number = string> { // ❌ Type 'string' does not satisfy the constraint 'number'.
  a: T;
}
What’s happening
  1. const raw: ApiResponse doesn't pass a type argument. Without the default this would be TS2314 ("requires 1 type argument"); with T = unknown it means ApiResponse<unknown>, so data is unknown and must be narrowed before use. unknown is a common safe default.
  2. ApiResponse<number[]> overrides the default like any explicit argument.
  3. createState() passes no argument, so there are no candidates for T. Instead of falling back to unknown, the checker uses the default: T = string, so value is string | undefined (the ? adds undefined).
  4. createState(5) has a candidate (5), so inference wins: T = number, and the default is ignored.
  5. Bad<T = string, U> puts a required parameter after an optional one, which fails with TS2706 — same rule as optional function parameters. AlsoBad has a default that breaks its own constraint, which fails with TS2344.
AdvancedDefault type parameters in libraries

Where you'll meet defaults: React's useState<S = undefined>() overload (so useState() with no argument gives undefined), event-emitter and store libraries (class Emitter<Events = {}>), and Promise-returning helpers like fetchJson<T = unknown>(url). A default of unknown instead of any keeps the "you must check this" safety (see unknown).

#More generic examples

Three patterns that come up constantly in real code and interviews. Each one is just the pieces above put together: inference, constraints, keyof and indexed access.

A typed groupBy

TypeScript
function groupBy<T, K extends PropertyKey>(items: T[], getKey: (item: T) => K): Partial<Record<K, T[]>> {
  const result: Partial<Record<K, T[]>> = {};
  for (const item of items) {
    const key = getKey(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const people = [
  { name: "Ann", role: "admin" as const },
  { name: "Bob", role: "user" as const },
  { name: "Cy", role: "admin" as const },
];

const byRole = groupBy(people, (p) => p.role);
// const byRole: Partial<Record<"admin" | "user", { name: string; role: "admin" | "user"; }[]>>
console.log(byRole.admin?.map((p) => p.name)); // [ 'Ann', 'Cy' ]
What’s happening
  1. T is inferred from people. Each object literal has role cast with as const, so the array's element type is { name: string; role: "admin" } or { name: string; role: "user" }, which the checker merges into { name: string; role: "admin" | "user" }.
  2. The callback (p) => p.role is context-sensitive, so it's checked after T is known: p is T, and the callback returns "admin" | "user", which becomes K. K extends PropertyKey (string | number | symbol) guarantees it can be used as an object key — see PropertyKey and keyof any.
  3. The return type Record<K, T[]> maps each key in "admin" | "user" to T[] (see Record<K, V>). It's wrapped in Partial because a group only exists if at least one item produced that key — byRole.user could be missing.
  4. In the body, result[key] ??= [] creates the array the first time a key is seen; .push(item) adds to it. Running it groups Ann and Cy under admin and Bob under user.
  5. Since ES2024 the built-in Object.groupBy(items, fn) does this and is typed the same way — it returns Partial<Record<K, T[]>>.
AdvancedA Result<T, E> type
TypeScript
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

function parseAge(input: string): Result<number, string> {
  const n = Number(input);
  return Number.isInteger(n) && n >= 0 ? ok(n) : err(`"${input}" is not a valid age`);
}

const r = parseAge("42");
if (r.ok) {
  console.log(r.value + 1); // 43 — r.value: number
} else {
  console.log(r.error.toUpperCase()); // r.error: string
}
What’s happening
  1. Result<T, E = Error> is a generic discriminated union: the literal ok property tells the two shapes apart. E defaults to Error, so Result<User> means "a User or an Error".
  2. ok(n) infers T = number and returns Result<number, never> — the error side is never because this value can't be an error. err("…") similarly returns Result<never, string>.
  3. Both are assignable to Result<number, string>: { ok: true; value: number } matches the first member, and { ok: false; error: string } matches the second. (never is assignable to everything, so the "impossible" side never gets in the way.)
  4. At the call site, if (r.ok) narrows r to the ok: true member, so r.value is number; in else it's the ok: false member, so r.error is string. You can't read value without checking first.
  5. This is how you make failures part of the return type instead of throwing — the generic parameters keep both the success type and the error type precise.
AdvancedA typed event emitter
TypeScript
type Listener<Args extends unknown[]> = (...args: Args) => void;

class Emitter<Events extends Record<string, unknown[]>> {
  private listeners: { [K in keyof Events]?: Listener<Events[K]>[] } = {};

  on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): void {
    (this.listeners[event] ??= []).push(listener);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(...args));
  }
}

const bus = new Emitter<{
  login: [userId: string];
  logout: [];
  error: [message: string, code: number];
}>();

bus.on("login", (id) => console.log(`login ${id}`)); // id: string
bus.on("error", (msg, code) => console.log(msg, code)); // msg: string, code: number
bus.emit("login", "u-42"); // "login u-42"
bus.emit("error", "boom", 500); // "boom 500"
bus.emit("logout");
bus.emit("error", "boom"); // ❌ Expected 3 arguments, but got 2.
bus.emit("signup"); // ❌ Argument of type '"signup"' is not assignable to parameter of type '"error" | "login" | "logout"'.
What’s happening
  1. Events is a map from event name to a tuple of argument types, constrained to Record<string, unknown[]>. Here login takes one string, logout takes nothing, error takes a string and a number. The labels like userId: are named tuple members — they show up as parameter names in the editor.
  2. on<K extends keyof Events> is the getValue pattern again: bus.on("error", …) infers K = "error", so the listener type is Listener<Events["error"]> = (...args: [message: string, code: number]) => void, which reads as (message: string, code: number) => void. That's why msg and code are typed without annotations.
  3. emit<K>(event: K, ...args: Events[K]) uses the tuple as a rest parameter type. bus.emit("error", "boom") makes ...args [message: string, code: number], so the call needs 3 arguments in total and gets 2 — TS2554.
  4. bus.emit("signup"): "signup" isn't in keyof Events, so the key fails its constraint with TS2345.
  5. The private listeners field is a mapped type: for each key K of Events, an optional array of listeners for that event. At runtime it's an ordinary object of arrays; all the safety is compile-time.

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.