TL;DR
Write reusable, type-safe code with TypeScript generics. Learn generic functions, interfaces, classes, and constraints.
Key concepts
- TypeScript generics
- generic functions TypeScript
- generic constraints
- TypeScript generic tutorial
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.
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 06-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
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"); // Error: "email" is not a key of person
// 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 }));
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 BDefault 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. Because wrap is a const (not a hoisted function) and each value line consumes the binding above it, only one order runs top-to-bottom and logs HI.
const inner = boxed.data;const wrap = <T,>(value: T): { data: T } => ({ data: value });const boxed = wrap("hi");const shout = inner.toUpperCase();console.log(shout);
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:
- Widen the query. In the
const short = ...line, change the predicate froma.minutesSpent < 25toa.minutesSpent > 25. Predict which check fails first before running.firstWherereturns the FIRST element that matches in array order — now that isa1("Types", 30 minutes), nota2. Soshort?.titleis"Types", and TODO 2's check fires withAssertionError: TODO 2: firstWhere should return the first matching element, typed as Tand'Types' !== 'Narrowing'. An instructive assert failure showing thatfindreturns the first match, and the predicate decides which that is. - 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 genericStoreworks for a completely different record type — anything with a stringid— so you getUser 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
Key Takeaways
- Generics preserve type information while keeping code reusable
- Use
<T>for type parameters — convention uses single uppercase letters - Constraints with
extendslimit what types are accepted keyof Tconstrains 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
anyto 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!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.