A pull request from a junior engineer last month had this signature:
The reviewer left a comment: "use a generic". The next iteration was:
The change was small. The win was that every call site now propagated its element type through the function. pickRandom([1,2,3]) returned number. pickRandom(['a','b','c']) returned string. The original any version had thrown that information away. That single PR captures the entire reason generics exist: keep the type information flowing through, instead of erasing it at every parameter boundary.
The argument I want to make in this piece is that generics are not an advanced feature. They are the way you write functions when the function does not care which type flows through. If you find yourself writing any[], an array of unknown, or a union of every possible input type, the function probably wants a generic. The intuition takes practice; the underlying mechanic is small.
What a generic actually is
A generic parameter is a type variable. The function (or class, or type alias) declares a placeholder, and the caller substitutes a concrete type at the call site. The compiler then checks the body of the function against the substituted type, instead of against an opaque any.
The function does not change for different types; it always returns its argument. What changes is the type the caller sees. The generic parameter T is plumbing, not behavior.
The compiler infers T from the call. You do not have to write identity<number>(42) (though you can). The inference engine works backwards from the argument shape to the smallest type that fits, and the result is propagated to the return type.
When the inference is "wrong" and how to fix it
Inference uses heuristics; sometimes the result is too narrow or too wide for what you wanted. Two common cases:
Too narrow: passing a literal narrows to the literal type, when you wanted the broader type.
If you want a wider type, write the type argument explicitly:
Too wide: when you want to preserve literal types, opt in with as const or annotate the parameter:
The T extends string constraint changed the inference behavior: when the type variable is constrained to a string subtype, the compiler keeps the literal. Without the constraint, it would have widened to string. This is a small piece of the inference rules and there are dozens more like it, but the practical advice is "if the inferred type is wrong, write the annotation explicitly".
Constraints: T extends X
Generics with no constraint are useful for "any type", but most useful generics have a constraint. The constraint says "T must be at least this much". Inside the function body, the compiler treats T as the constraint, so you can use the constrained shape.
The constraint T extends { name: string } says the items must have a name field, but the function preserves the full input type, including fields the function never touched. The caller gets back the same shape they put in, not a stripped-down view.
This is the pattern that makes generics earn their place: when a function does not care about the full shape, but should return the full shape unchanged, a constrained generic is the only honest signature. Without it, you either widen the return type (losing fields) or narrow the input type (rejecting valid callers).
Multiple type parameters
Functions can have several type parameters; the compiler infers them all from the call.
The independence is the point: A and B do not have to be related. This is rare in practice; usually multiple type parameters appear together because one constrains the other.
K extends keyof T ties the second parameter to the first: K must be a key of T. The return type T[K] is an indexed access, which the compiler resolves to the type of that specific property. The function is type-safe in a way that no any-based equivalent could be.
Default type parameters
Like function parameters, type parameters can have defaults. They activate when the caller does not supply (and inference cannot find) a value.
I reach for defaults in library code where the most common usage is "the obvious thing" but a small minority of callers want to specialise. The defaults make the common path short.
Generic classes and interfaces
The same machinery works on classes and interfaces.
The class is parameterised at instantiation; every method that mentions T resolves against the parameter. Generic interfaces work the same way.
The default E = Error makes Result<User> shorthand for Result<User, Error>.
Variance, briefly
Variance is the rule for how Container<A> relates to Container<B> when A is a subtype of B. TypeScript is mostly bivariant for parameter positions in function types (which is unsound but pragmatic) and covariant for return positions. In practice, the rules rarely matter at the application level; they bite when you write higher-order generics that take callbacks.
The case that catches people: an array of subclass items can be assigned to an array of base-class items, even though writing into the array would break type safety. TypeScript allows this for ergonomics; runtime safety is the user's responsibility.
The variance rule lets you write code that mostly works; the compiler does not stop you from creating soundness holes. The defense is to mark such collections ReadonlyArray<T> when the holes would matter, which forbids the unsafe push.
What confuses people the first month
Three traps I have watched derail engineers learning generics, in roughly the order they appear.
"T is a value, not a type, right?" No. T is purely a type-level placeholder. You cannot use it in runtime code. if (item instanceof T) does not compile, and even if it did, the runtime has no idea what T was at the call site (TypeScript erases types at compile time). The runtime equivalent of T at runtime is a constructor reference passed as a regular parameter.
Constraint vs default vs argument. These three are not interchangeable. A constraint (T extends X) restricts which types are allowed. A default (T = X) provides a value when none is inferred or given. An explicit argument (fn<X>(...)) sets T to X at the call site. Mixing the three up produces confusing error messages.
"Why is the return type T | undefined instead of T?" Because the function has a path where T could not be produced, and the compiler is forcing you to acknowledge it. The fix is to handle the undefined branch at the call site; the alternative ("just cast it") works until production hits the undefined branch and the cast was a lie.
A pragmatic test for "should this be a generic?"
When I am writing a function and wondering whether to introduce a generic parameter, I run through three questions.
Does the function care which exact type flows through? If yes, write specific types, no generic needed. If no (the function works on the type structurally, or just passes the type through), a generic is probably right.
Should the caller's type be preserved on the way out? If yes, the return type needs to mention T, which means T has to be a parameter, which means a generic.
Are there fields the caller's type has that the function does not need? If yes, a constrained generic preserves the unused fields without forcing the function to know about them.
If two of the three are "yes", the function is asking for a generic. If none of them are "yes", a non-generic signature with concrete types is clearer.
Where the next mile is
If utility types are the working set of a typed codebase, generics are the working set of a typed library. The places where generics get genuinely subtle (variance in callback types, conditional types over generic parameters, recursive type aliases, mapped types with key remapping, distributive vs non-distributive conditionals) are where library authors live. Application code rarely needs that level of machinery.
The practical roadmap from "I get generics" to "I use generics on instinct" runs roughly: write a few signatures with <T>, then a few with <T extends X>, then a few with <T, K extends keyof T>. After that, conditional types and infer (the topic of a separate piece) become tractable. After that, mapped types start clicking. Each step builds on the previous. There is no magic threshold; there is only practice on real code, and the willingness to delete an any and replace it with <T> whenever the original signature was lying about the type.
The journey from confused to confident took me about two years of writing TypeScript daily. It can probably go faster now, with better materials and stricter codebases as practice ground. But the move from "any-everywhere" to "generic-by-default" is the single largest jump in TypeScript fluency I have personally seen, in myself and in juniors I have mentored. The return on the time invested is enormous, because every signature you write afterwards is more honest and more useful than the version that erased the type.
