Generics

TypeScript · Lesson 14 of 28 6 min read Updated

Suppose you write a function that returns the first item of an array. It should work for an array of names, an array of prices and an array of trains, and the result should keep the right type each time. Writing one version per type is repetitive, and using any throws the types away. Generics solve this: they let a function, type or class work with many types while staying fully typed.

You have already used generics without noticing: Array<string>, Record<string, number> and Map<string, number> are all generic types. In this lesson you will write your own.

Your first generic function

A generic function has a type parameter in angle brackets, usually named T (for "type"). T is a placeholder that is filled in each time the function is called.

TypeScriptRun it yourself
function firstItem<T>(items: T[]): T | undefined {
  return items[0];
}

const city = firstItem(["Jaipur", "Pune", "Kochi"]); // T is string
const price = firstItem([1299, 499]);               // T is number
const nothing = firstItem<boolean>([]);              // T given explicitly

console.log(city?.toUpperCase());
console.log(price?.toFixed(2));
console.log(nothing);
Output
JAIPUR
1299.00
undefined

When you call firstItem(["Jaipur", ...]), TypeScript infers T as string from the argument, so city has the type string | undefined and toUpperCase() is allowed. You can also pass the type yourself, as in firstItem<boolean>([]), which is useful when there is nothing to infer from.

Compare this with function firstItem(items: any[]): any. That version also runs, but city would be any, and a typo like city.toUppercase() would go unnoticed. Generics keep the connection between the input type and the output type.

Several type parameters

A function can have more than one type parameter. Here K is the key type and V is the value type:

TypeScriptRun it yourself
function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

function mapValues<V, R>(record: Record<string, V>, fn: (value: V) => R): Record<string, R> {
  const result: Record<string, R> = {};
  for (const [key, value] of Object.entries(record)) {
    result[key] = fn(value);
  }
  return result;
}

const entry = pair("PNR", 4521890); // [string, number]
console.log(entry);

const fees = { aarav: 12000, meera: 9500, rohan: 15000 };
const withLabel = mapValues(fees, (fee) => `₹${fee.toLocaleString("en-IN")}`);
console.log(withLabel);
Output
[ 'PNR', 4521890 ]
{ aarav: '₹12,000', meera: '₹9,500', rohan: '₹15,000' }

In mapValues, TypeScript infers V as number from fees and R as string from what the callback returns, so withLabel is a Record<string, string>.

Constraints with extends

Inside a generic function, T could be anything, so TypeScript only lets you do what works for every type. To use a property such as .length, add a constraint: T extends { length: number } means "T can be any type that has a numeric length".

TypeScript
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest("Lucknow", "Pune");
longest([1, 2, 3], [4, 5]);
longest(10, 20);
Output
app.ts(7,9): error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.

Strings and arrays have length, so the first two calls work and return a string and a number array. Numbers do not, so the third call is rejected.

A very common constraint is K extends keyof T, which means "K must be one of the property names of T". It lets you write safe helpers that read properties by name:

TypeScriptRun it yourself
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

const students = [
  { name: "Isha", marks: 88, city: "Kochi" },
  { name: "Farhan", marks: 74, city: "Lucknow" },
];

const names = pluck(students, "name");   // string[]
const marks = pluck(students, "marks");  // number[]

console.log(names, marks);
console.log(Math.max(...marks));
Output
[ 'Isha', 'Farhan' ] [ 88, 74 ]
88

pluck(students, "age") would be a compile error, because "age" is not a key of the student type. The return type T[K][] means "an array of whatever type that property has". You will study keyof and T[K] in detail in keyof, typeof & Indexed Access.

Generic types and classes

Type aliases, interfaces and classes can be generic too. A typical example is the result of an API call, which has the same outer shape whatever data it carries:

TypeScriptRun it yourself
type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

class Queue<T> {
  #items: T[] = [];

  enqueue(item: T): void {
    this.#items.push(item);
  }

  dequeue(): T | undefined {
    return this.#items.shift();
  }

  get size(): number {
    return this.#items.length;
  }
}

function show<T>(result: ApiResult<T>, format: (data: T) => string): string {
  return result.ok ? format(result.data) : `Failed: ${result.error}`;
}

const tokens = new Queue<number>();
tokens.enqueue(101);
tokens.enqueue(102);
console.log(tokens.dequeue(), tokens.size);

const results: ApiResult<number[]>[] = [
  { ok: true, data: [12, 13, 14] },
  { ok: false, error: "Server busy" },
];
for (const result of results) {
  console.log(show(result, (seats) => `Seats ${seats.join(", ")}`));
}
Output
101 1
Seats 12, 13, 14
Failed: Server busy

Queue<number> only accepts numbers: tokens.enqueue("A1") would be an error. The same Queue class works for patient tokens, print jobs or messages, each with full type checking.

Default type parameters

A type parameter can have a default, used when nothing is given or inferred: type ApiResult<T = unknown> = .... Then ApiResult on its own means ApiResult<unknown>. Defaults work like default function parameters, but for types.

const type parameters

When a generic function receives a literal such as ["S", "M", "L"], TypeScript normally widens it to string[]. Adding const before the type parameter (TypeScript 5.0+) asks for the narrow, as const style type instead:

TypeScript
function options<const T extends readonly string[]>(values: T): T {
  return values;
}

const sizes = options(["S", "M", "L"]); // type: readonly ["S", "M", "L"]

This saves callers from writing as const themselves.

Common mistakes

  • Using a type parameter only once. In function log<T>(x: T): void, T connects nothing; x: unknown is simpler. Generics are for linking types, for example an input and an output.
  • Reaching for any instead. If a function works for many types, a generic keeps the types; any loses them.
  • Forgetting a constraint. Accessing item.length on a bare T is an error; add T extends { length: number }.
  • Over-engineering. Three nested type parameters with complex constraints are hard to read. Start simple.

Key points

  • Generics let one function, type or class work with many types while keeping full type checking.
  • Type parameters such as <T> are usually inferred from the arguments, but you can pass them explicitly.
  • Constraints (T extends { length: number }, K extends keyof T) limit what types are allowed and unlock their properties.
  • Type aliases, interfaces and classes can be generic, for example ApiResult<T> or Queue<T>.
  • Type parameters can have defaults (<T = unknown>), and const type parameters infer literal types.

Exercise

Write a generic function groupBy<T, K extends string>(items: T[], getKey: (item: T) => K): Record<K, T[]> that groups items into lists by a key. Use it to group these orders from a bakery in Pune by status: { id: 1, status: "pending" }, { id: 2, status: "delivered" }, { id: 3, status: "pending" }. Log the ids in each group.

Show solution
TypeScript
function groupBy<T, K extends string>(items: T[], getKey: (item: T) => K): Record<K, T[]> {
  const groups = {} as Record<K, T[]>;
  for (const item of items) {
    const key = getKey(item);
    (groups[key] ??= []).push(item);
  }
  return groups;
}

type Order = { id: number; status: "pending" | "delivered" };
const orders: Order[] = [
  { id: 1, status: "pending" },
  { id: 2, status: "delivered" },
  { id: 3, status: "pending" },
];

const byStatus = groupBy(orders, (o) => o.status);
console.log(byStatus.pending.map((o) => o.id));   // [ 1, 3 ]
console.log(byStatus.delivered.map((o) => o.id)); // [ 2 ]

K is inferred as "pending" | "delivered", so byStatus.pending is checked and autocompleted. For plain JavaScript there is also the built-in Object.groupBy(), which returns a similar object.

Quick Quiz

1. In function firstItem<T>(items: T[]): T | undefined, what is T for firstItem([5, 9])?

2. What does T extends { length: number } do?

3. Which call fails for pluck<T, K extends keyof T>(items: T[], key: K) with student objects that have name and marks?

4. When is a generic type parameter unnecessary?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call