TypeScript Generics Explained: Functions, Interfaces and Constraints
ProgrammingLesson 3 of 45 min read
What you'll learn
- Write a generic function and explain how the type argument is inferred
- Create generic interfaces and classes such as a paged result and a typed cache
- Limit what a type parameter can be with
extendsandkeyof - Recognise when generics are unnecessary and a simpler signature is better
On this page
Imagine a music library app that returns albums in pages of ten, tracks in pages of fifty and artists in pages of twenty. The pagination logic, slicing the list and working out whether more pages exist, is identical every time. Only the item type differs. You can copy the function three times, or write it once with any and lose all type information, or you can write it once with a type parameter and keep every item type exact. The third option is what generics are.
A type parameter is a function parameter for types
In function firstOr<T>(items: T[], fallback: T): T, the <T> declares a placeholder type. When you call the function, TypeScript works out T from the arguments (this is inference), so you almost never write the type argument by hand. The signature then guarantees something a plain any could not: the result has the same type as the items.
// library.ts - generic helpers for a small music-library app
// 1. A generic function: the type flows from input to output
function firstOr<T>(items: T[], fallback: T): T {
return items.length > 0 ? items[0] : fallback;
}
const n = firstOr([3, 5], 0); // number
const s = firstOr(["a"], "none"); // string
console.log(n, s);
// 2. A generic interface for paged API results
interface Page<T> {
items: T[];
page: number;
hasMore: boolean;
}
function paginate<T>(all: T[], page: number, size: number): Page<T> {
const start = (page - 1) * size;
return {
items: all.slice(start, start + size),
page,
hasMore: start + size < all.length,
};
}
const tracks = ["intro", "verse", "chorus", "bridge", "outro"];
console.log(paginate(tracks, 1, 2));
console.log(paginate(tracks, 3, 2));
// 3. A constraint: T must have an id
interface HasId { id: number }
function indexById<T extends HasId>(rows: T[]): Map<number, T> {
return new Map(rows.map((row) => [row.id, row]));
}
const byId = indexById([{ id: 7, label: "north" }, { id: 9, label: "south" }]);
console.log(byId.get(9)?.label);
// 4. keyof: pick a property by name, safely
function pluck<T, K extends keyof T>(rows: T[], key: K): T[K][] {
return rows.map((row) => row[key]);
}
const stops = [
{ id: 1, name: "Harbour", zone: 2 },
{ id: 2, name: "Airport", zone: 4 },
];
console.log(pluck(stops, "name"), pluck(stops, "zone"));
// 5. A generic class with a default type parameter
class Cache<V = string> {
private store = new Map<string, V>();
set(key: string, value: V): void {
this.store.set(key, value);
}
get(key: string): V | undefined {
return this.store.get(key);
}
get size(): number {
return this.store.size;
}
}
const titles = new Cache(); // Cache<string>
titles.set("a", "Intro");
const counts = new Cache<number>();
counts.set("plays", 1200);
console.log(titles.get("a"), counts.get("plays"), counts.get("skips"), counts.size);Output
3 a
{ items: [ 'intro', 'verse' ], page: 1, hasMore: true }
{ items: [ 'outro' ], page: 3, hasMore: false }
south
[ 'Harbour', 'Airport' ] [ 2, 4 ]
Intro 1200 undefined 1Walk through what each part buys you. paginate(tracks, 1, 2) returns Page<string>, so result.items[0] is a string with full autocomplete; call it with an array of album objects and you get Page<Album> with no extra code. indexById accepts any row type as long as it has a numeric id, and it returns a map of that same row type, so byId.get(9)?.label still knows about label. pluck uses K extends keyof T so the key must be a real property name, and the return type T[K][] follows the property: pluck(stops, "zone") is a number[]. The class sets a default V = string, so new Cache() works without a type argument. Runs with node --experimental-strip-types since it uses no enums or other runtime-only TypeScript syntax.
What generics refuse to let you do
Constraints are what keep generics honest. Inside function longest<T>(a: T, b: T), TypeScript knows nothing about T, so a.length is an error until you say T extends { length: number }. The messages below are real TypeScript 5.9 output; the first block is wrong on purpose.
function firstOr<T>(items: T[], fallback: T): T { return items.length > 0 ? items[0] : fallback; }
const bad = firstOr([1, 2], "none"); // (1)
function indexById<T extends HasId>(rows: T[]): Map<number, T> { /* ... */ }
const rows = [{ label: "x" }];
indexById(rows); // (2)
function pluck<T, K extends keyof T>(rows: T[], key: K): T[K][] { /* ... */ }
pluck([{ id: 1 }], "name"); // (3)
function longest<T>(a: T, b: T) { return a.length > b.length ? a : b; } // (4)
const w: string[] = wrap(3); // (5), wrap<T>(x: T): T[]Output
(1) Argument of type 'string' is not assignable to parameter of type 'number'.
(2) Argument of type '{ label: string; }[]' is not assignable to parameter of type 'HasId[]'.
Property 'id' is missing in type '{ label: string; }' but required in type 'HasId'.
(3) Argument of type '"name"' is not assignable to parameter of type '"id"'.
(4) Property 'length' does not exist on type 'T'.
(5) Type 'number[]' is not assignable to type 'string[]'.
Type 'number' is not assignable to type 'string'.Error (1) is TypeScript fixing T to number from the first argument and then rejecting the string fallback, which is exactly the protection you want. The fix for (4) is a constraint: function longest<T extends { length: number }>(a: T, b: T): T, after which longest("abc", "de") and longest([1], [1, 2]) both work, while longest(10, 20) is rejected because a number has no length.
Generics in everyday signatures
Most of the generics you meet are not ones you write. Promise<T> is why await fetchTrack() gives you a Track rather than a vague value, Array<T> is why tracks.map(...) knows each element, and Map<string, number> is why .get("plays") returns number | undefined. When you write an async helper, the return type is just the generic applied: async function loadPage(n: number): Promise<Page<string>>. If you ever need to unwrap it, the built-in Awaited<ReturnType<typeof loadPage>> produces Page<string> again without you repeating the shape.
A useful habit is to write the concrete version first. Make paginateTracks(tracks: string[], ...) work, then notice that the body never touches anything string-specific, and only then replace string with T. Generics extracted from working code are usually simpler and better named than ones designed in the abstract.
Common mistakes and the errors they produce
- Using `any` instead of a type parameter.
function first(items: any[]): anycompiles, but the caller getsanyback and every later mistake goes unreported. - Forgetting a constraint. Using a property on a bare
TgivesProperty 'length' does not exist on type 'T'. Addextends. - Passing explicit type arguments that fight inference.
firstOr<number>([1], "x")is rejected. Let inference work, and specify arguments only when it cannot (such asnew Map<string, number>()). - Mixing element types without noticing.
const e = []followed by pushes is inferred loosely; declareconst e: Track[] = []so generic helpers receive the rightT. - Over-engineering. Three nested conditional types for a function used twice is worse than two small concrete functions. If you cannot explain the signature to a teammate, simplify it.
- Unions where a generic is needed.
function wrap(x: string | number): (string | number)[]loses the link between input and output;wrap<T>(x: T): T[]keeps it.
How this shows up in real projects and interviews
You consume generics daily even if you never write one: Array<T>, Promise<T>, Map<K, V>, and useState<T> in React are all generic. You write them when building reusable pieces: API clients (get<T>(url): Promise<T>), paged lists, form helpers and stores. The one caution is that get<User>("/api/user") only claims the response is a User; the compiler cannot check the network, so it is a promise you make, not a verified fact. In interviews, expect "write a generic groupBy" or "explain extends keyof", and a follow-up about when not to use generics. The data-shaping side of this is practised in JavaScript fundamentals course (see its lesson on array methods).
Try it yourself
Write groupBy<T, K extends string | number>(items: T[], getKey: (item: T) => K): Record<K, T[]> and use it to group a list of { title: string; year: number } albums by decade (Math.floor(year / 10) * 10). Print the result, hover over it to confirm the inferred type, and try calling it with a getKey that returns a boolean to see the constraint fail.
Show a hint
Start with const out = {} as Record<K, T[]>, then loop and use (out[key] ??= []).push(item). Returning Record<K, T[]> may need that one cast, which is fine in a small generic helper.
Key takeaways
- A type parameter lets one function or type work for many types while keeping each exact; TypeScript usually infers it.
- Constrain with
extendsto say what a type parameter must have, andK extends keyof Tto accept only real property names. - Generic interfaces like
Page<T>and classes likeCache<V>remove duplicated code across item types. - A type parameter should link things; if it appears once, use a plain type.
get<T>()for a network call is an assertion, not a check; validate data from outside your program.
Test yourself
A trick from this lesson's ride: Generic = same type in, same type out, with a blank for TypeScript to fill Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What are generics in TypeScript and why use them?
Generics are type parameters that let you write one function, interface or class that works with many types while keeping the exact type information. For example, Array<T> holds items of one specific type T. They avoid both duplicated code and the loss of safety that comes from using any.
What does T extends keyof T mean in TypeScript?
K extends keyof T says the type parameter K must be one of the property names of T. It is used in helpers like pluck(items, "name") so a typo in the key is a compile error and the return type follows the chosen property.
When should I not use generics in TypeScript?
Skip generics when the type parameter appears only once in the signature, when there is just one concrete type involved, or when the generic version is harder to read than two simple functions. Generics are worth it when they connect an input type to an output type or link several parameters.