Skip to main content
PProfileMatch

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 extends and keyof
  • Recognise when generics are unnecessary and a simpler signature is better

On this page

  1. A type parameter is a function parameter for types
  2. What generics refuse to let you do
  3. Generics in everyday signatures
  4. Common mistakes and the errors they produce
  5. How this shows up in real projects and interviews

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
// 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 1

Walk 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.

generic_errors.ts (does not compile)
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[]): any compiles, but the caller gets any back and every later mistake goes unreported.
  • Forgetting a constraint. Using a property on a bare T gives Property 'length' does not exist on type 'T'. Add extends.
  • Passing explicit type arguments that fight inference. firstOr<number>([1], "x") is rejected. Let inference work, and specify arguments only when it cannot (such as new Map<string, number>()).
  • Mixing element types without noticing. const e = [] followed by pushes is inferred loosely; declare const e: Track[] = [] so generic helpers receive the right T.
  • 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 extends to say what a type parameter must have, and K extends keyof T to accept only real property names.
  • Generic interfaces like Page<T> and classes like Cache<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.

Ride this lesson

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.