Generics That Don't Make People Cry
Photo: Unsplash
Open a file full of <T extends Record<string, unknown>, K extends keyof T, V = T[K]> and most developers quietly close the tab. Generics have a reputation for being the part of TypeScript where readable code goes to die. But that reputation comes from misuse, not from generics themselves. A well-placed generic is one of the clearest things you can write: it says "this function works for any type, and the output relates to the input like so."
The difference between generics that help and generics that hurt comes down to a handful of habits. This article is the set of rules I wish someone had handed me before I wrote my first incomprehensible T.
A generic is a relationship, not a placeholder
The first mental shift: a type parameter is not "some type I do not care about." It is a way to connect the types of inputs and outputs. If a function returns whatever type you pass in, a generic captures that link.
// Without a generic, the relationship is lost.
function firstBad(arr: unknown[]): unknown {
return arr[0];
}
// With a generic, output type tracks input type.
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // n: number | undefined
const s = first(["a", "b"]); // s: string | undefinedThe whole point of the T is the link between the array element type and the return type. If your generic parameter appears only once, in a single position, it is probably not pulling its weight. A type parameter earns its place by tying two or more spots together. The TypeScript handbook chapter on generics frames this well: generics describe how types flow through a function.
Let inference do the typing
The second habit: almost never write the type arguments at the call site. TypeScript infers them from the values you pass, and forcing them by hand is both noisy and fragile.
first<number>([1, 2, 3]); // redundant; T is already inferred as number
first([1, 2, 3]); // let inference workExplicit type arguments are a smell that says either your generic is structured wrong or you are working around an inference gap. Most of the time, restructuring the signature so the compiler can infer the parameter from an argument is the better fix. Good generics are invisible at the call site; the caller writes ordinary code and the types just line up.
If callers constantly have to write myFn<SomeType>(...), that is feedback. Either a parameter type should drive the inference, or the function is trying to be generic over something it cannot see. Redesign before you document the workaround.
Constrain only as much as you use
The third habit, and the one that prevents the scary signatures: add a constraint with extends only for the properties you actually touch, and no more. Over-constraining makes a function less reusable; under-constraining makes it unsafe.
// We only need a `.length`, so constrain to exactly that.
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest([1, 2], [1, 2, 3]); // arrays: fine
longest("hi", "there"); // strings: also fine, both have lengthBy constraining to { length: number } instead of, say, T extends unknown[], the function works for strings, arrays, and any custom type with a length. The constraint documents the requirement precisely: "I need a length, nothing else." This is the opposite of the kitchen-sink constraint that demands far more structure than the body ever reads.
Name parameters like variables
The fourth habit is purely about readability, and it is wildly underrated. Single-letter names like T, K, and V are fine for tiny utilities where the meaning is obvious, but for anything with domain meaning, give the parameter a real name.
// Hard to follow.
function index<T, K extends keyof T>(item: T, key: K): T[K] { /* ... */ }
// Reads like a sentence.
function index<Item, Key extends keyof Item>(item: Item, key: Key): Item[Key] {
return item[key];
}Key extends keyof Item tells the reader exactly what is going on; K extends keyof T makes them decode it. The convention of T comes from a time when generics were rare and short; modern TypeScript codebases lean toward descriptive names for anything non-trivial. Your future self reviewing this at midnight will thank you.
Default type parameters for ergonomics
The fifth habit smooths over the most common complaint: that generics make simple calls verbose. Default type parameters let a generic stay flexible while keeping the common case clean.
// `Meta` defaults to an empty object so most callers ignore it.
interface ApiResponse<Data, Meta = Record<string, never>> {
data: Data;
meta: Meta;
}
type Simple = ApiResponse<User>; // meta defaults
type Paged = ApiResponse<User[], PageInfo>; // override when neededDefaults mean a generic can offer power to the callers who need it without taxing the ones who do not. This is how good library types feel light despite being highly parameterized. The handbook covers default type parameters alongside constraints.
When you should not use a generic
The honest counterpoint: not everything should be generic. If a function only ever handles one concrete type, a generic adds indirection for no benefit. If you find yourself reaching for conditional types and infer to make a generic behave, step back and ask whether two plain overloads would be clearer. Generics are a tool for capturing genuine relationships across types, not a badge of sophistication. The best codebases use them sparingly and obviously.
Takeaways
- A type parameter should connect two or more positions; if it appears once, you probably do not need it.
- Let inference supply type arguments; explicit
<T>at the call site is usually a smell. - Constrain with
extendsonly to the properties the body actually uses, keeping functions reusable. - Name parameters descriptively (
Item,Key) once they carry domain meaning, not justTandK. - Use default type parameters to keep the common call simple while staying flexible.
- Skip generics when there is no real relationship to capture; clarity beats cleverness.

