The Day TypeScript Generics Finally Clicked For Me
For the longest time, I thought I understood generics. I could write Array and Promise without flinching, and if you'd asked me in an interview to explain what a generic was, I'd have said something like "it's a type that takes a type parameter" and moved on quickly before anyone asked a follow-up question. The truth is I was pattern-matching on syntax I'd seen a thousand times, not actually reasoning about it. I want to walk through the specific sequence of examples that took me from that fake understanding to something real, because I think a lot of people are stuck exactly where I was.
Why do I even need this
The first honest question to ask is: what problem do generics solve that I can't solve another way? Say you're writing a function that wraps a value in an array.
function wrapInArray(value: any) {
return [value];
}
This works, technically. But any is a trapdoor — it tells TypeScript to stop checking. If I call wrapInArray(5) and get back number[], great, but TypeScript doesn't actually know that. It thinks the return type is any[], so I could push a string into it and nobody would complain until runtime. My first instinct as a junior-ish developer was to write overloads for every type I cared about, which is exactly the kind of copy-paste hell generics exist to prevent.
function wrapInArray(value: T): T[] {
return [value];
}
That's it. That's the whole insight that took me embarrassingly long to internalize: T is not a real type, it's a placeholder that gets filled in at the call site, and TypeScript tracks that substitution through the rest of the function. Call it with a number, and T becomes number for that call. Call it with a { name: string } object, and T becomes that shape. The function stays generic; each call site is specific. Once I said that sentence to myself out loud, a lot of the codebases I'd been reading suddenly made sense retroactively.
The part nobody explains well: constraints
Where I got stuck next was constraints, because most tutorials introduce extends in generics right after introducing T for the first time, and the two ideas get mushed together in your head. Here's the scenario that made it click for me. I was writing a function to pluck a property off an object by key, something you'd reach for constantly in a React codebase.
function getProp(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Rupraj", active: true };
const name = getProp(user, "name"); // string
const bad = getProp(user, "email"); // Error: Argument of type '"email"' is not assignable
The K extends keyof T part is doing real work. Without it, key would just be string, and I could pass any garbage string in and get back any. With it, TypeScript computes keyof T — the literal union "id" | "name" | "active" — and forces key to be one of those. Then T[K] (an indexed access type) tells TypeScript exactly which property type comes back. The autocomplete you get in VS Code when you call getProp(user, " and it pops up only the three valid keys — that's not magic, that's this constraint doing its job. Seeing that autocomplete populate correctly was honestly the first time generics felt like they were working *for* me instead of being homework I had to do.
Conditional types are where it got weird, then obvious
The next wall I hit was conditional types, which read like ternaries for the type system:
type IsString = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
I remember staring at this thinking, why would I ever need this. The answer showed up in a real bug. I was writing a small wrapper around fetch for an internal tool, and I wanted the return type to change depending on whether the caller passed a parser function.
type FetchResult = T extends (data: unknown) => infer R ? R : unknown;
async function fetchJson any) | undefined>(
url: string,
parser?: T
): Promise> {
const res = await fetch(url);
const data = await res.json();
return (parser ? parser(data) : data) as FetchResult;
}
This is the moment infer stopped being scary. infer R just means "capture whatever type is in this position and let me refer to it as R." If T is a function that returns a User, then FetchResult resolves to User. If no parser is passed, T is undefined, the conditional falls through to unknown, which is an honest answer since we genuinely don't know the shape. I'd seen infer in library type definitions — React Query and Zod both lean on it heavily — and always skipped past it as "vendor code I don't need to understand." Writing my own tiny version of it, badly, in a throwaway script was what made the library code legible afterward.
Inference is the actual point
Here's the thing that reframed everything for me: generics aren't primarily about writing flexible functions, they're about giving TypeScript enough information to infer the rest without you writing types by hand everywhere. Compare these two:
// explicit, tedious, and you have to keep it in sync
function identity(value: number): number;
function identity(value: string): string;
function identity(value: boolean): boolean;
function identity(value: any): any {
return value;
}
// generic, and TypeScript figures out T on its own
function identity(value: T): T {
return value;
}
Every time I call identity(42), TypeScript looks at the argument, infers T = number, and substitutes it into the return type without me ever writing identity. That inference step is the entire value proposition. I used to think being explicit with type parameters was more "correct" somehow, and I'd litter my code with useState even when TypeScript could trivially infer string from the initial value. Now I only specify type parameters explicitly when inference genuinely can't figure it out — usually when the generic only appears in the return position, like useState where the null default would otherwise infer as just null.
Where it actually clicked
The real click moment came from a utility type I needed for a form library at work, something close to this:
type DeepPartial = T extends object
? { [K in keyof T]?: DeepPartial }
: T;
interface Settings {
theme: { color: string; darkMode: boolean };
notifications: { email: boolean; sms: boolean };
}
function updateSettings(patch: DeepPartial) {
// merge logic
}
updateSettings({ theme: { darkMode: true } }); // valid, nested and partial
This combines a conditional type, a mapped type ([K in keyof T]), and recursion, all in about four lines. When I finally traced through how TypeScript resolves this — object gets picked apart property by property, each property re-enters the same conditional, primitives bottom out and stop recursing — I understood that generics aren't a separate feature bolted onto TypeScript, they're the mechanism that makes the whole type system programmable. Partial, Pick, Omit, ReturnType, all the built-in utility types you've been using without a second thought, are just generics someone else wrote using the exact same tools I'd just used. Once I opened lib.es5.d.ts and actually read the definition of Omit, it wasn't intimidating anymore, it was just familiar syntax doing something I now understood.
If you're where I was, my honest advice is to stop reading about generics and start writing a handful of small, useless utility types purely to see what TypeScript does with them. Write your own DeepPartial. Write your own Awaited. Break them on purpose and read the error messages. The abstraction only clicks once you've pushed on it yourself, not when you've read the tenth blog post explaining it — including, ironically, this one.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.