Generics in TypeScript Without the Confusion
I explain TypeScript generics as functions for types — from the duplication they replace to constraints, defaults, inference, and why they vanish at runtime.

You’ve written a pair of functions like this before. Different names, different types, same body:
function firstTag(tags: string[]): string | undefined {
return tags[0];
}
function firstScore(scores: number[]): number | undefined {
return scores[0];
}
Copy-paste gets you the third one free, and the fourth. The alternative — one function typed with any — trades the duplication for silence: autocomplete gone, the compiler blind to everything downstream. The TypeScript Handbook opens its generics page with exactly this trade: specific types duplicate, any accepts everything and forgets what came in.
Generics fix it, and the confusion around them dissolves once you hold one sentence: a generic is a function for types. The <T> is a parameter list. Not magic, not an advanced corner of the language — an argument passed at the type level. By the end of this post you’ll read identity<T> like any other signature, know when to constrain, default, or annotate the parameter, and meet the twist that makes the whole thing click: generics are already gone by the time your code runs.
Series: TypeScript, part 3 of 4. Start with Part 1: TypeScript for JavaScript Developers: Where to Start · Previous: Interfaces vs Types in TypeScript
On this page
- The duplication problem generics solve
- Generics are functions for types
- Constraints put a floor under the type parameter
- Generic interfaces, aliases, and classes
- Defaults and inference: annotate only when you must
- You already use generics every day
- Generics vanish at runtime
- Read the angle brackets as parameters
The duplication problem generics solve
What the two functions above share is structure: take an array, return its first element or undefined. What they lack is a way to say that once. A string[] parameter won’t accept number[], so each type earns its own copy — and its own future bug fix. The collapse into one any version looks like relief:
function first(items: any[]): any {
return items[0];
}
One function, zero duplication, zero knowledge. The return type any tells the caller’s compiler nothing: no autocomplete on the result, no checks on anything you do with it. The handbook’s diagnosis is precise — passing a number in tells you only that “any type could be returned.” What you need is a way to capture the type on the way in and hand it back out. A value parameter can’t do that, because parameters carry values.
You need a parameter that carries a type.
Generics are functions for types
Strip the problem to its skeleton and you get the identity function — return what you were handed. The handbook’s first generic looks like this:
function identity<T>(value: T): T {
return value;
}
Read it with the functions-for-types model. <T> declares the parameter: this function is generic over a type we’ll call T. Then T is used twice — as the type of value and as the return type — threading the input’s type through to the output. The docs call T a type variable, a variable that works on types rather than values. It’s the type-level twin of the value parameters you’ve written forever.
Calling it works like calling anything else, with the type as the argument:
const name = identity("refactor"); // T binds to string
const count = identity(42); // T binds to number
You can pass the type explicitly — identity<string>("refactor") — but the everyday form is the one above. The compiler infers the binding from the value argument, and the handbook treats that inference as the common way to call generics, reaching for explicit angle brackets mainly when inference fails. The payoff is on the caller’s side: name is a string to the compiler, autocomplete included. One declaration replaced every copy-pasted variant, and unlike the any version, nothing was forgotten on the way through.
A note on naming: the docs spell their parameter Type for readability. In real code you’ll see T for short signatures and descriptive names like Result<Response> when the parameter appears in several places.
Constraints put a floor under the type parameter
An unconstrained T is a promise that the function works for every type — so the compiler won’t let you touch anything on it:
function logLength<T>(value: T): T {
console.log(value.length);
// Error: Property 'length' does not exist on type 'T'.
return value;
}
That error is the system working. Inside the function, T could be number, and numbers have no length. If your function genuinely needs something from its parameter, say so with extends:
function logLength<T extends { length: number }>(value: T): T {
console.log(value.length); // fine now
return value;
}
T now accepts any type with a length: number — strings, arrays, anything shaped right — and rejects the rest. logLength(3) fails to compile; logLength("refactor") passes. That’s the whole trade: a constraint narrows who can call the function and, in exchange, unlocks what the function can do. The handbook builds its version on an interface named Lengthwise, which is the tidier pattern once the constraint grows past one property.
Constraints can also reference each other, which is where they start feeling precise rather than restrictive:
function getProperty<T, Key extends keyof T>(obj: T, key: Key) {
return obj[key];
}
Key is constrained to the actual keys of T, so getProperty(user, "nmae") is a compile-time error instead of a runtime undefined surprise.
Generic interfaces, aliases, and classes
The same parameter list attaches to interfaces, type aliases, and classes. Put it on the declaration and every member can use it:
interface Result<T> {
data: T;
error?: string;
}
const userResult: Result<User> = { data: user };
One declaration now describes Result<User>, Result<Order>, Result<Invoice> — the handbook’s point about moving the parameter onto the interface is that it becomes visible to all members, the way Dictionary<string> says more than a bare Dictionary. Type aliases take parameters the same way: type Pair<A, B> = { first: A; second: B }. The interface-versus-alias decision from part 2 of this series is unchanged — generics ride on either.
Classes work identically. A typed stack, in full:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
Stack<Tag> and Stack<number> are distinct types from one class, and push accepts only the element type the stack was created with. Notice pop returns T | undefined — the same empty-collection honesty find needed in JavaScript array methods: an empty stack has nothing to return. One doc detail worth keeping: generic classes are generic on the instance side only — static members can’t use T.
Defaults and inference: annotate only when you must
Two habits keep generic code readable: give rarely-needed parameters a default, and annotate only when inference fails.
A default makes a type argument optional. This wrapper defaults T to unknown, so callers who don’t specify get honesty instead of any:
async function fetchJson<T = unknown>(url: string): Promise<T> {
const response = await fetch(url);
return response.json();
}
Defaults arrived in TypeScript 2.3 and come with documented rules: required parameters can’t follow optional ones, and a default must satisfy any extends constraint on the same parameter. I’m writing against TypeScript 5.7, released in November 2024, where all of this behaviour is stable.
So when do you write the angle brackets yourself? Three situations cover most of it:
- Almost never, when inference already binds
T.identity("refactor")beatsidentity<string>("refactor")— the explicit form is noise. - When
Tcan’t be inferred. InfetchJson,Tappears only in the return type; no argument carries it, so inference has nothing to work from and the default applies. That’s why typedfetchwrappers are the classic case for explicit arguments:fetchJson<User>("/api/me"). - When inference picks the wrong thing. Pass an empty array and TypeScript infers
never[]— an array type with no possible elements. Writeidentity<string[]>([])and the binding is right again.
Try this
- Open the TypeScript Playground and paste the
identityfunction.- Call
identity("refactor")and hover the variable.- Now call
identity([])and hover again.Expected result: the first hover shows
Tbound tostring. The second showsnever[], notstring[]— inference did its job with an empty list, and explicit angle brackets are how you correct it.
You already use generics every day
Here’s the reframe that made this click for me. string[] is syntax sugar for Array<string> — an instantiation of a generic type. Promise<User> is a generic holding the value an await will unwrap. Map<string, Tag>, Set<number>, the typed wrappers around Response.json() — all generic applications. If you’ve written any TypeScript, you’ve been passing type arguments since your first file.
Once that lands, signatures stop being intimidating. identity<T> is no different from Array<T> — one you write, one you consume. That’s also why the fetchJson helper earns its keep: one small generic turns “this endpoint returns JSON” into “this endpoint returns a User”, and the compiler threads T through every await after it.
One honest caveat, carried over from part 1: the T in fetchJson<User> is a claim, not a check. The response body has never met your compiler. If the shape of runtime data matters — and for a network response it usually does — parse it through a runtime validator and let the validated type flow in.
Generics vanish at runtime
Now the surprise, and it explains half the confusing generics questions out there: none of this exists at runtime. identity<T> compiles to:
function identity(value) {
return value;
}
The angle brackets are gone. The TypeScript FAQ states it flatly: generics are erased during compilation, so there is no value T at runtime — the same erasure behind the handbook’s promise that type annotations never change the runtime behaviour of your program.
The consequence trips everyone up the first time: you cannot write if (value instanceof T). There’s no T in the running program to compare against — instanceof needs a constructor that exists as a value, and T is a compile-time binding that evaporates. It’s the same reason new T() doesn’t compile.
When you truly need the type at runtime, pass it as a value. The FAQ and the handbook both show the pattern — take the class itself as a parameter, through a construct signature:
function create<T>(ctor: { new (): T }): T {
return new ctor();
}
create(Tag) receives the constructor as a real value, so new ctor() works, and the return type comes along for free. For the check people want from instanceof T, the same FAQ section passes the constructor and runs instanceof against that instead. Erasure isn’t a bug to work around so much as the design: TypeScript checks, strips, and gets out of JavaScript’s way.
Read the angle brackets as parameters
Generics confused me for longer than I’d like to admit, because I treated <T> as ceremony to memorise rather than syntax I already understood. The shift is small: value parameters take values, type parameters take types, and both are just arguments. Stack<Tag> is a call. extends is a contract on who may call. A default is a fallback argument. The whole apparatus gets checked and bound, then erased before launch — which is exactly why it costs nothing at runtime and checks nothing at runtime.
One action for this week: find a pair of functions in your codebase that differ only by type, and collapse them into one generic helper. Add a constraint if it touches members, a default if callers mostly agree, and let inference do the rest.
There’s a final layer this post deliberately skipped: the built-in generic types — Partial<T>, Pick<T, K>, Record<K, V> — which use everything covered here to build new types from old ones. They’re the subject of the next post in this series, and after this one they’ll read as plain function applications.