Skip to editor content
learningtypescript.orglesson 10 of 25

Generics

Generics let you write code that works with any type while still being type-safe. Instead of using any, generics preserve type information through your code, catching errors at compile time.

What You Already Know

By the end of this lesson you will build a Store<T extends { id: string }> — one storage service, written once, that works for any record carrying a string id, plus a generic query helper whose return type still knows the element type it was given. You will also be able to say what a constraint actually restricts and what it buys you in exchange.

This is the tracker-service milestone, and this is the load-bearing version of it: the capstone's storage is exactly a Tracker<T extends { id: string }> over a Map, so the class you finish here is the shape the final project keeps. You have already used a generic function. Back in Functions you wrote and called identity<T>, and you predicted what T infers to when the type argument is left off — so the syntax and the inference are not new here. What is new is control: constraints that limit what T may be, and the patterns that let one function serve many types without falling back to any.

Generic Functions

A generic function uses a type parameter (conventionally T) that gets replaced with an actual type when the function is called.

// Without generics — loses type information
function firstElementAny(arr: any[]): any {
  return arr[0];
}

// With generics — preserves type information
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = firstElement([1, 2, 3]); // type: number | undefined
const str = firstElement(["a", "b", "c"]); // type: string | undefined

console.log(`First number: ${num}`);
console.log(`First string: ${str}`);

// Multiple type parameters
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}

const nameAge = pair("Alice", 30);
console.log(`Pair: ${nameAge[0]}, ${nameAge[1]}`);

// Generic identity with explicit type
function identity<T>(value: T): T {
  return value;
}

const result = identity<string>("hello");
console.log(`Identity: ${result}`);

Generic Interfaces

Interfaces can also use type parameters to create flexible, reusable contracts.

interface Container<T> {
  value: T;
  getValue(): T;
  setValue(newValue: T): void;
}

class Box<T> implements Container<T> {
  constructor(public value: T) {}

  getValue(): T {
    return this.value;
  }

  setValue(newValue: T): void {
    this.value = newValue;
  }
}

const numberBox = new Box(42);
console.log(`Number box: ${numberBox.getValue()}`);
numberBox.setValue(100);
console.log(`Updated: ${numberBox.getValue()}`);

const stringBox = new Box("hello");
console.log(`String box: ${stringBox.getValue()}`);

// Generic API response
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

type User = { id: number; name: string };
type Product = { id: number; title: string; price: number };

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "Alice" },
  status: 200,
  message: "OK"
};

const productResponse: ApiResponse<Product> = {
  data: { id: 1, title: "Laptop", price: 999 },
  status: 200,
  message: "OK"
};

console.log(`\nUser: ${userResponse.data.name}`);
console.log(`Product: ${productResponse.data.title} - $${productResponse.data.price}`);

Recall

Without scrolling up: the ApiResponse<T> here takes a type parameter in angle brackets. You already met exactly this in Interfaces and Types — a type ApiResponse<T> = { data: T; ... }. So what does the <T> actually do, and what does that make this lesson's ApiResponse<User> versus ApiResponse<Product>?

Generic Constraints

Use extends to constrain what types a generic can accept.

// Constrain T to have a length property
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}

logLength("hello");
logLength([1, 2, 3]);
logLength({ length: 10, name: "test" });
// logLength(42); // Error: number doesn't have 'length'

// Constrain to object keys.
// TODO before you read on: logLength constrained T with `extends HasLength`.
// The next function constrains K so that `key` can only be a name that exists
// on T. Write down the constraint you would use, then check it against the one
// below and against the rejected call two lines under it.
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: "Bob", age: 30, city: "NYC" };

console.log(`\nName: ${getProperty(person, "name")}`);
console.log(`Age: ${getProperty(person, "age")}`);
// getProperty(person, "email");
// TS2345: Argument of type '"email"' is not assignable to parameter of type
// '"name" | "age" | "city"'. That union IS keyof typeof person — the constraint
// turned the key argument into a closed set of the object's own field names.

// Generic with multiple constraints
interface Identifiable {
  id: number;
}

interface Nameable {
  name: string;
}

function displayEntity<T extends Identifiable & Nameable>(entity: T): string {
  return `#${entity.id}: ${entity.name}`;
}

console.log(displayEntity({ id: 1, name: "Alice", role: "admin" }));
console.log(displayEntity({ id: 2, name: "Bob", age: 25 }));

What a constraint actually rejects (two no-run demos)

Both commented-out lines above are the point of their constraints, but a comment cannot show you an error. These fences are marked no-run because their whole job is to fail to compile: the runner strips the types and would run each call happily.

First, the HasLength constraint. logLength(42) is rejected because a number has no length:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}

logLength(42);

Under tsc --strict:

error TS2345: Argument of type 'number' is not assignable to parameter of type 'HasLength'.

Second, the keyof constraint. "email" is not a key of person:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: "Bob", age: 30, city: "NYC" };

getProperty(person, "email");

Under tsc --strict — note that the compiler spells out the exact set of keys it would accept:

error TS2345: Argument of type '"email"' is not assignable to parameter of type '"name" | "age" | "city"'.

Now the runtime shadow for both. Nothing here is checked when the program runs, so each bad call proceeds and produces a value the constraint existed to prevent:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

logLength("hello");
logLength(42 as unknown as HasLength);

const person = { name: "Bob", age: 30, city: "NYC" };
const email = getProperty(person, "email" as keyof typeof person);
console.log("email:", email);
console.log("email length:", (email as string).length);

The first two lines print Length: 5 and then Length: undefined — a number has no length, so the read gives undefined and the program carries on regardless. Then email: undefined, and the last line throws TypeError: Cannot read properties of undefined (reading 'length'). That is the pattern worth internalising: a constraint does not add a runtime check, it prevents you from writing the call at all. Skip the compiler and the same code gives you undefined at the point of use and a crash somewhere later, where it is much harder to trace back.

For the Predict below, the compiler error on the invalid key reads, exactly:

error TS2345: Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'.

Predict

getProp is constrained with K extends keyof T, so its return type is T[K] — the precise type of that one property. Given user = { name: string; age: number }, one of the two calls below is a compiler error and the other returns a usable number. Which is which, and what is the first thing you observe? (The playground strips types and would run this either way — it prints nothing and reports no error — so reason about the compiler, not the run.)

function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const user = { name: "Ada", age: 36 };

const n = getProp(user, "age");    // call A
const e = getProp(user, "email");  // call B

Default Type Parameters

Generic types can have defaults, just like function parameters.

// Default type parameter
interface Collection<T = string> {
  items: T[];
  add(item: T): void;
  getAll(): T[];
}

class SimpleCollection<T = string> implements Collection<T> {
  items: T[] = [];

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

  getAll(): T[] {
    return [...this.items];
  }
}

// Uses default type (string)
const names = new SimpleCollection();
names.add("Alice");
names.add("Bob");
console.log(`Names: ${names.getAll().join(", ")}`);

// Explicit type
const scores = new SimpleCollection<number>();
scores.add(95);
scores.add(87);
console.log(`Scores: ${scores.getAll().join(", ")}`);

// Generic utility function
function createArray<T = string>(length: number, value: T): T[] {
  return Array.from({ length }, () => value);
}

const strings = createArray(3, "hi");
const numbers = createArray(4, 0);

console.log(`Strings: ${strings}`);
console.log(`Numbers: ${numbers}`);

Arrange the code

Reassemble a program that defines a generic wrap (as a const arrow), boxes the string 'hi' into { data: 'hi' }, pulls the inner value back out, upper-cases it, and logs it. The lines are shuffled. Put them back in an order that runs, then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?

  1. const inner = boxed.data;
  2. const wrap = <T,>(value: T): { data: T } => ({ data: value });
  3. const boxed = wrap("hi");
  4. const shout = inner.toUpperCase();
  5. console.log(shout);

Debug

This is meant to be a reusable helper: sum one numeric field across any list of records, choosing the field by name. It compiles, it runs, and it fails its own check with a number that should not be possible. Commit a hypothesis about what the loop is actually adding up, then fix it so the total comes out as 50 — and so the compiler would have caught the mistake in the first place.

import assert from "node:assert";

interface Activity {
id: string;
title: string;
minutesSpent: number;
}

const activities: Activity[] = [
{ id: "a1", title: "Types", minutesSpent: 30 },
{ id: "a2", title: "Narrowing", minutesSpent: 20 },
];

// Sum one numeric field across any list of records, chosen by name.
function sumBy<T>(list: T[], field: string): number {
let total = 0;
for (const item of list) {
	total += (item as any)[field];
}
return total;
}

const total = sumBy(activities, "minutes");
assert.strictEqual(total, 50, "expected 50 total minutes, got " + total);
console.log("Total minutes:", total);

Expected output:

Total minutes: 50

Try It Yourself

Reading about generics is not the same as building a reusable, type-safe service with them. This is a build task: a small program that reports its own pass/fail. You finish a generic Store<T extends { id: string }> — the exact generics-backed storage the tracker capstone is built on — plus a generic query helper. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.

The pieces reuse exactly what this lesson taught: a constrained type parameter that lets the service read item.id safely, a Map<string, T> for storage, and a generic function whose return type preserves the element type. The starter has the entity, the class shell, and the checks — you write only the logic inside each member.

Build

Finish the build. A generic class and one generic function are stubbed, and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that piece is missing, then implement them until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// The entities are given. Do NOT change these.
interface Activity {
id: string;
title: string;
minutesSpent: number;
}

const activities: Activity[] = [
{ id: "a1", title: "Types", minutesSpent: 30 },
{ id: "a2", title: "Narrowing", minutesSpent: 20 },
];

// TODO 1: finish the generic Store<T> — constrained so every T has a string id.
//   Back it with the Map<string, T>. add(item): store it keyed by item.id and
//   return this (to chain). get(id): return this.items.get(id). size getter:
//   return this.items.size. The constraint T extends { id: string } is what lets
//   add read item.id safely.
//   const s = new Store<Activity>(); s.add(activities[0]); s.get("a1")?.title -> "Types"
class Store<T extends { id: string }> {
private items = new Map<string, T>();

add(item: T): this {
	// your code here
	return this; // replace this
}

get(id: string): T | undefined {
	// your code here
	return undefined; // replace this
}

get size(): number {
	// your code here
	return 0; // replace this
}
}

// TODO 2: write a generic firstWhere<T> that returns the first element matching
//   a predicate, or undefined if none match. Preserve the element type T.
//   Hint: Array.prototype.find already does exactly this.
//   firstWhere(activities, (a) => a.minutesSpent < 25)?.title -> "Narrowing"
function firstWhere<T>(list: T[], predicate: (item: T) => boolean): T | undefined {
// your code here
return undefined; // replace this
}

// --- Build checks: these must all pass. Do not edit below this line. ---
const store = new Store<Activity>();
store.add(activities[0]).add(activities[1]);
assert.strictEqual(store.size, 2, "TODO 1: Store.add should store items and size should report the count");
assert.strictEqual(store.get("a1")?.title, "Types", "TODO 1: Store.get should return the stored item by id");

const short = firstWhere(activities, (a) => a.minutesSpent < 25);
assert.strictEqual(short?.title, "Narrowing", "TODO 2: firstWhere should return the first matching element, typed as T");

console.log("All checks passed.");
console.log("Stored:", store.size);
console.log("Got a1:", store.get("a1")?.title);
console.log("First short activity:", short?.title);

Expected output:

All checks passed.
Stored: 2
Got a1: Types
First short activity: Narrowing

Once it passes, try two variations and predict each before running:

  1. Widen the query. In the const short = ... line, change the predicate from a.minutesSpent < 25 to a.minutesSpent > 25. Predict which check fails first before running. firstWhere returns the FIRST element that matches in array order — now that is a1 ("Types", 30 minutes), not a2. So short?.title is "Types", and TODO 2's check fires with AssertionError: TODO 2: firstWhere should return the first matching element, typed as T and 'Types' !== 'Narrowing'. An instructive assert failure showing that find returns the first match, and the predicate decides which that is.
  2. Reuse the store for a different type. After the checks pass, add const users = new Store<{ id: string; name: string }>(); users.add({ id: "u1", name: "Ada" }); console.log("User store:", users.get("u1")?.name, "| size:", users.size); below the logs. Predict the new line before running. The same generic Store works for a completely different record type — anything with a string id — so you get User store: Ada | size: 1. This changes the echoed output, not any check, and is the whole payoff of the generic constraint: one service, many entity types.

Capstone milestone

Milestone — the tracker service (the generics-backed shape). The tracker's storage is a generic service over any record with a string id: Store<T extends { id: string }> backed by a Map, exposing typed add / get / size. The Store you just built is that shape. Confirm you can build a constrained generic service that preserves the element type end to end.

Hint: This is the tracker-service milestone, shared with *Classes And OOP* (a class is one valid shape) and *Utility Types* (Partial/Pick DTOs). The generic constraint is the load-bearing part here: in the capstone this exact shape becomes Tracker<T extends { id: string }> over a Map.

  • Wrote a generic class constrained with T extends { id: string }
  • Backed it with a Map<string, T> so identity lookups are type-safe
  • get returns T | undefined and add reads item.id thanks to the constraint
  • Wrote a generic function (firstWhere) whose return type preserves the element type T

Transfer

The Store you just built is written once and works for any record with a string id, because T extends { id: string } tells the compiler what it may assume about T. Rust writes the same service as Store<T: HasId>, where HasId is a trait the stored type must implement. Both are one definition serving many types, and both reject a type that does not qualify. What is the same, and where does the resemblance stop?

// TypeScript
class Store<T extends { id: string }> {
private items = new Map<string, T>();
add(item: T): this { this.items.set(item.id, item); return this; }
}

// Rust
// trait HasId { fn id(&self) -> String; }
// struct Store<T: HasId> { items: HashMap<String, T> }
// a type qualifies only by an explicit impl HasId for ThatType

Key Takeaways

  • Generics preserve type information while keeping code reusable
  • Use <T> for type parameters — convention uses single uppercase letters
  • Constraints with extends limit what types are accepted
  • keyof T constrains to valid keys of an object type
  • Default type parameters reduce boilerplate for common use cases
  • Generic interfaces and classes create flexible, type-safe abstractions

Pro Tip: If you find yourself using any to make code flexible, that's usually a sign you should use generics instead. Generics give you the same flexibility while keeping full type safety. Start simple with one type parameter and add constraints only when needed.

Next Steps

Your generic Store<T> took a whole entity type — but real services often need variations of a type: a create form that omits the id, an update that touches only some fields. Next, you'll learn TypeScript's built-in utility types — Partial, Pick, Omit, and friends — that transform an existing type into exactly the shape you need without redefining it.

Ready to continue? Head to Utility Types!

Next lesson

Utility Types

Master TypeScript utility types like Partial, Pick, Omit, and Record. Transform existing types without repetition.

22 min