A generic function says “this works for many types”. A constraint says “but not for every type”. Getting the balance right is most of what makes generic code pleasant to call, and the classic interview questions in this area (typing getProperty, explaining why a function returning T won’t compile) are all about where the constraint sits and what it promises.
What an unconstrained T lets you do
Start with a function that returns the longer of two values:
function longest<T>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}gen1.ts(2,12): error TS2339: Property 'length' does not exist on type 'T'.
gen1.ts(2,24): error TS2339: Property 'length' does not exist on type 'T'.Inside the body, the compiler has to assume T could be anything the caller chooses, including number or undefined. So it only allows operations that work on every type, which is almost nothing. That’s the point of generics: the implementation must be correct for every possible T, not just the ones you had in mind.
A constraint narrows “every possible T” to “every T that has at least this shape”:
function longestOk<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longestOk("abc", "de"); // string
longestOk([1, 2], [3]); // number[]
longestOk(10, 20); // errorgen1.ts(10,11): error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.Strings and arrays both have a numeric length, so both are accepted, and each call gets its own T back. Numbers don’t, so the call fails at the call site with a clear message. Note that extends in a constraint is about assignability, the same as everywhere else in TypeScript. It has nothing to do with class inheritance; a plain object literal with length: 3 would also satisfy it.
Using one T for both parameters is itself a relationship. longestOk("abc", [1, 2]) fails with Argument of type 'number[]' is not assignable to parameter of type '"abc"', because TypeScript inferred T from the first argument and the second didn’t fit. If you wanted to allow mixing, you’d need two type parameters.
Keep T, not the constraint
The most common mistake with constraints is writing the constraint type where T should be. Here are two versions of a helper that indexes records by ID:
function indexById<T extends { id: string }>(items: readonly T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item]));
}
function indexByIdLossy(items: readonly { id: string }[]): Map<string, { id: string }> {
return new Map(items.map((item) => [item.id, item]));
}
const people = [{ id: "u1", name: "Ada" }];
indexById(people).get("u1")?.name; // string | undefined
indexByIdLossy(people).get("u1")?.name; // errorgen2.ts(13,20): error TS2339: Property 'name' does not exist on type '{ id: string; }'.Both accept the same arguments. The difference is what comes out. The generic version remembers that the caller passed objects with name, because T is inferred as { id: string; name: string }. The non-generic version forgets everything except id. The constraint describes what the function needs; T carries what the caller actually has. When a function returns or stores the values it was given, keep T in the output.
The opposite mistake is a generic that does nothing. function logIt<T>(value: T): void uses T exactly once, so it relates nothing to anything and could be written value: unknown. A useful sanity check is that each type parameter should appear at least twice: in two parameters, or in a parameter and the return type.
Tying parameters together with keyof
Constraints can refer to other type parameters, which is how you express relationships between arguments. The standard example is a typed setter:
function setField<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
obj[key] = value;
}
const cfg = { port: 8080, host: "localhost" };
setField(cfg, "port", 9090); // ok
setField(cfg, "port", "9090"); // error
setField(cfg, "prot", 1); // errorgen2.ts(20,23): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
gen2.ts(21,15): error TS2345: Argument of type '"prot"' is not assignable to parameter of type '"host" | "port"'.Read the signature left to right. T is inferred from cfg. K must be one of T’s keys, so the typo is caught and the error even lists the valid keys. T[K] is the type of the property at that key, so once K is "port", the value must be a number. Three arguments, one consistent story. Without the relationship you’d write key: string, value: unknown and check nothing.
The order matters for readability, not for inference: TypeScript infers all type parameters from all arguments together. But declaring the “source” parameter first and the dependent ones after it (T, then K extends keyof T) matches how people read the signature.
“Could be instantiated with a different subtype”
This error confuses people more than any other in generic code. Try to build a T yourself:
function withDefaults<T extends { retries: number }>(partial: Partial<T>): T {
return { retries: 3, ...partial };
}gen1.ts(26,3): error TS2322: Type '{ retries: number; } & Partial<T>' is not assignable to type 'T'.
'{ retries: number; } & Partial<T>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{ retries: number; }'.The compiler is right. The caller picks T. If they call withDefaults<{ retries: number; url: string }>({}), the function promises to return an object with a url, and it would return { retries: 3 }. Your object satisfies the constraint, but the constraint is only the minimum; T can be any subtype of it, with more required properties or narrower literal types.
The fix is to make the signature honest, and there are two reasonable ways.
The first is to stop promising T. Declare the return type as { retries: number } & Partial<T>, and the function compiles without a cast, because that’s exactly what the spread produces. Callers get an object where retries is definitely present and everything else is optional, which is the truth.
The second keeps T as the return type but asks the caller for every field you can’t invent: input: Omit<T, "retries"> & Partial<Pick<T, "retries">>. Now the caller must supply url and anything else T requires. The compiler still can’t prove that spreading those pieces rebuilds T (it reports the same TS2322 with a longer type in the message), so this version needs one as T. The difference from a blind cast is that the signature now makes the cast true, and a short comment saying so helps the next reader.
What you shouldn’t do is keep the original Partial<T> parameter and add as T. That makes the error go away by repeating the false promise, and the caller who asked for a url gets undefined at runtime.
Interview practice
Write pluck(items, key) that takes an array of objects and a key, and returns an array of that property’s values. pluck([{ id: 1, name: "Ada" }], "name") should be typed string[], and a key that doesn’t exist should be a compile error. Then explain each part of the signature.
Answer
function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
return items.map((item) => item[key]);
}T is the element type, inferred from the array, so callers keep their full object type. K extends keyof T limits the key to real property names of T, which gives the compile error for a typo and autocomplete for valid keys. The return type T[K][] is “an array of whatever type lives at K”, so plucking "name" gives string[] and plucking "id" gives number[]. readonly T[] means the function promises not to mutate the input, which also lets callers pass readonly arrays. One follow-up worth anticipating: if a caller passes a union of keys, like a variable typed "id" | "name", then T[K] becomes number | string and the result is (string | number)[], which is the correct, if less precise, answer.