Phase 4 · Generics · Lesson 4.1
IntermediateGenerics: the basics
Why generics beat `any` and overloads, how to write generic functions, interfaces and classes, and how TypeScript infers type arguments at the call site.
20 min
You write a helper that returns the first item of an array. Typed as any[], it works for everything and tells you nothing: the caller gets any back and the type checker goes blind. Typed as number[], it's safe but you need a copy for strings, users and orders. Generics give you both: one function, and the caller still gets the exact type back.
The problem: any loses the link
function firstAny(items: any[]): any {
return items[0];
}
const n = firstAny([1, 2, 3]);
// ^? const n: any
n.toUpperCase(); // no error, crashes at runtime: n is a numberany in, any out. TypeScript forgot that numbers went in, so it can't know a number comes out. What we need is a way to say "whatever type goes in is the type that comes out".
A type parameter is a variable for types
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]);
// ^? const n: number | undefined
const s = first(["a", "b"]);
// ^? const s: string | undefined<T> declares a type parameter. It's a placeholder, filled in fresh for each call. At first([1, 2, 3]) TypeScript sees number[] where it expects T[], so it solves T = number, and the return type becomes number | undefined.
The key idea: a generic connects types. Here it connects the element type of the input to the return type. That connection is what any can't express.
Why not overloads?
Overloads list every accepted type by hand:
function wrap(value: string): string[];
function wrap(value: number): number[];
function wrap(value: string | number): (string | number)[] {
return [value];
}
wrap("a"); // string[]
// @ts-expect-error -- No overload matches this call.
wrap(true);That's a closed list. Every new type means a new overload. A generic covers all of them in one signature:
function wrap<T>(value: T): T[] {
return [value];
}
const a = wrap(true);
// ^? const a: boolean[]Use overloads when the relationship between input and output changes shape per case (a string gives back X, a number gives back something unrelated). Use generics when the relationship is the same for every type.
Inference vs explicit type arguments
Most of the time you let TypeScript infer T from the arguments. You can also pass it explicitly in angle brackets:
function wrap<T>(value: T): T[] {
return [value];
}
const inferred = wrap("hi");
// ^? const inferred: string[]
const explicit = wrap<string | number>("hi");
// ^? const explicit: (string | number)[]
// @ts-expect-error -- Argument of type 'number' is not assignable to parameter of type 'string'.
wrap<string>(42);Pass the type explicitly when inference can't see it (nothing in the arguments mentions T) or when you want a wider type than the argument suggests, like string | number above.
A classic case where inference has nothing to work with:
function emptyList<T>(): T[] {
return [];
}
const unknowns = emptyList();
// ^? const unknowns: unknown[]
const names = emptyList<string>();
// ^? const names: string[]With no argument to learn from, T falls back to its constraint, which is unknown when there isn't one.
How inference picks a type
Inference collects candidates from each argument and then picks one. The rules that matter in practice:
1. Literals are widened, unless the call site is const-like. A bare T in the return position keeps the literal when the result goes into a const:
function identity<T>(value: T): T {
return value;
}
const a = identity("hi");
// ^? const a: "hi"
let b = identity("hi");
// ^? let b: stringInside the call T is "hi". The let then widens it to string, exactly as let b = "hi" would.
2. Several candidates for the same T must agree. TypeScript picks a best common type. If none of them fits all the others, it does not invent a union; it picks the first candidate and reports the mismatch:
function pair<T>(a: T, b: T): [T, T] {
return [a, b];
}
const nums = pair(1, 2);
// ^? const nums: [number, number]
// @ts-expect-error -- Argument of type 'string' is not assignable to parameter of type 'number'.
pair(1, "two");
const mixed = pair<number | string>(1, "two"); // explicit union is fine3. An array literal is one candidate, not many. The array's own type is inferred first (and arrays do form unions), then matched against T[]:
function first<T>(items: T[]): T | undefined {
return items[0];
}
const x = first([1, "two"]);
// ^? const x: string | number | undefinedQuick check
What is the type of r?
function same<T>(a: T, b: T): [T, T] {
return [a, b];
}
const r = same(1, "x");Multiple type parameters
A function can relate several types. map needs one for the input element and one for the output:
function map<T, U>(items: T[], fn: (item: T) => U): U[] {
const out: U[] = [];
for (const item of items) out.push(fn(item));
return out;
}
const lengths = map(["a", "bb"], (s) => s.length);
// ^? const lengths: number[]Inference runs left to right: T = string comes from the array, which gives the callback parameter s its type (contextual typing), and then U = number comes from the callback's return.
Generic arrow functions
Arrow functions put the type parameters before the parameter list:
const last = <T>(items: T[]): T | undefined => items[items.length - 1];
const l = last([true, false]);
// ^? const l: boolean | undefinedGeneric interfaces and type aliases
Types can be generic too. You pass the type argument when you use the type:
interface Box<T> {
value: T;
}
type Pair<A, B> = { first: A; second: B };
const box: Box<number> = { value: 1 };
const p: Pair<string, boolean> = { first: "ok", second: true };
// @ts-expect-error -- Generic type 'Box<T>' requires 1 type argument(s).
const bad: Box = { value: 1 };Unlike functions, generic types never infer their arguments from an annotation. Box on its own is an error; you have to write Box<number>.
Where the type parameter lives matters
These two look similar but mean different things:
// The caller of the TYPE picks T once, for the whole function value.
type Mapper<T> = (value: T) => T;
// Each CALL picks T fresh: this is a generic function type.
type GenericMapper = <T>(value: T) => T;
const double: Mapper<number> = (n) => n * 2;
const id: GenericMapper = (value) => value;
id("a"); // T = string for this call
id(1); // T = number for this call
// @ts-expect-error -- Argument of type 'string' is not assignable to parameter of type 'number'.
double("a");Mapper<number> is a function that only handles numbers. GenericMapper is a function that works for any T, and you can't assign a numbers-only function to it.
Generic classes
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
const stack = new Stack<string>();
stack.push("a");
// @ts-expect-error -- Argument of type 'number' is not assignable to parameter of type 'string'.
stack.push(1);
const inferred = new Stack();
// ^? const inferred: Stack<unknown>new Stack() with no argument to infer from gives Stack<unknown>, which is rarely what you want. Pass the type argument when constructing an empty container.
Class type parameters belong to instances. Static members exist once for the whole class, so they can't use T:
class Registry<T> {
// @ts-expect-error -- Static members cannot reference class type parameters.
static defaultItem: T;
}The "used once" smell
A type parameter earns its place by connecting two or more positions: an input to an output, or two inputs to each other. If T appears only once, it connects nothing:
// Smell: T appears once, so it adds nothing over `unknown`.
function logBad<T>(value: T): void {
console.log(value);
}
// Same safety, simpler signature.
function log(value: unknown): void {
console.log(value);
}A worse variant uses T only in the return type. It looks type-safe but is a cast in disguise:
function parse<T>(json: string): T {
return JSON.parse(json); // JSON.parse returns any, so this compiles
}
const user = parse<{ name: string }>('{"nme":"Ada"}');
user.name.toUpperCase(); // compiles, crashes: the key is misspelled in the dataNothing checks that the JSON matches T. Returning unknown and validating is honest; a return-only T just hides an as.
Spot the error
This generic class looks fine, but one line doesn't compile. Which one, and why?
class Cache<T> {
private map = new Map<string, T>();
static create(): Cache<T> {
return new Cache();
}
get(key: string): T | undefined {
return this.map.get(key);
}
}Show the answer
static create(): Cache<T>: Static members cannot reference class type parameters. A static method belongs to the class itself, not to a Cache<string> or Cache<number> instance, so there's no T in scope. Give the static method its own type parameter:
class Cache<T> {
private map = new Map<string, T>();
static create<U>(): Cache<U> {
return new Cache<U>();
}
get(key: string): T | undefined {
return this.map.get(key);
}
}
const c = Cache.create<number>();
// ^? const c: Cache<number>Try it
Play with inference: change the arguments and hover the results.
function zip<A, B>(as: A[], bs: B[]): [A, B][] {
const length = Math.min(as.length, bs.length);
const out: [A, B][] = [];
for (let i = 0; i < length; i++) out.push([as[i], bs[i]]);
return out;
}
const zipped = zip([1, 2, 3], ["one", "two", "three"]);
// ^? const zipped: [number, string][]
const [firstPair] = zipped;
console.log(firstPair); // [1, "one"]
// Try: zip<number, string>([1], [2]) and read the error.▶ Try it in the TypeScript Playground
Quick check
Which line is a compile error?
function toPair<K, V>(key: K, value: V): [K, V] {
return [key, value];
}
toPair("id", 1); // Line A
toPair<string, number>("id", 1); // Line B
toPair<string>("id", 1); // Line CQuick check
What is the type of result?
function makeArray<T>(): T[] {
return [];
}
const result = makeArray();Recap
- A type parameter (
<T>) is a type-level variable, filled in fresh for each use. - Generics connect types: the input's type determines the output's.
anybreaks that link. - Prefer generics when the relationship is the same for every type; overloads when the shape changes per case.
- Type arguments are usually inferred from arguments; pass them explicitly when there's nothing to infer from or you want a wider type.
- No partial inference: pass all type arguments or none (unless defaults exist).
- Separate arguments must agree on
T; TypeScript picks the first candidate and errors instead of inventing a union. - An uninferred
Tbecomes its constraint (unknownby default). - Generic types (
Box<T>) always need explicit arguments; static class members can't use the class'sT. - A type parameter used only once is a smell; used only in the return type, it's a hidden cast.
Interview cards
1 / 7