Foundations Updated 2026-09 View as Markdown

Generics

A generic is a type with a hole in it. They look intimidating and the everyday use is genuinely simple.

You have already used generics without noticing:

typescript
const names: Array<string> = [];        // Array with a hole filled by string
const scores: Map<string, number> = new Map();
const later: Promise<User> = fetchUser();

Array on its own is incomplete — an array of what? The type parameter fills the hole. Writing your own works the same way.

A generic function#

The problem generics solve: without them, a reusable function loses type information.

typescript
function first(items: any[]): any {
  return items[0];
}

const n = first([1, 2, 3]);      // n is any. we lost the number.

With a type parameter, the type flows through:

typescript
function first<T>(items: T[]): T {
  return items[0];
}

const n = first([1, 2, 3]);           // number
const s = first(["a", "b"]);          // string
const u = first<User>([]);            // User, stated explicitly

<T> declares the parameter; T[] and : T use it. You almost never pass it explicitly — TypeScript infers it from the argument.

T is only a convention. <Item> is just as valid and often clearer.

Constraints#

An unconstrained T could be anything, so you can barely touch it:

typescript
function longest<T>(a: T, b: T): T {
  return a.length > b.length ? a : b;    // error: T has no 'length'
}

extends narrows what T may be:

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

longest("hello", "hi");           // string
longest([1, 2], [1, 2, 3]);       // number[]
longest(10, 20);                  // error — numbers have no length

Read T extends X as "T is at least an X". The return type is still the specific type passed in, which is the whole point — longest("a", "b") gives you a string, not a {length: number}.

Constraining by key#

keyof plus a constraint gives you type-safe property access:

typescript
function pluck<T, K extends keyof T>(item: T, key: K): T[K] {
  return item[key];
}

const user = { id: "1", email: "a@b.com", age: 36 };

pluck(user, "email");     // string
pluck(user, "age");       // number
pluck(user, "nope");      // error: not a key of the object

keyof T is the union of T's property names — here "id" | "email" | "age". T[K] is the type of that property. This pattern turns up constantly in real code.

Generic interfaces and classes#

typescript
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  save(item: T): Promise<void>;
}

class InMemoryRepo<T extends { id: string }> implements Repository<T> {
  private items = new Map<string, T>();

  async findById(id: string): Promise<T | null> {
    return this.items.get(id) ?? null;
  }

  async save(item: T): Promise<void> {
    this.items.set(item.id, item);
  }
}

const users = new InMemoryRepo<User>();
const found = await users.findById("1");     // User | null

One implementation, fully typed for every entity you use it with.

Defaults#

typescript
interface ApiResponse<T = unknown> {
  status: number;
  data: T;
}

const a: ApiResponse = { status: 200, data: "anything" };       // T is unknown
const b: ApiResponse<User> = { status: 200, data: ada };

Note the default is unknown, not any — keep the safe default even here.

A practical example#

typescript
async function fetchJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as T;
}

const user = await fetchJson<User>("/api/users/1");   // typed as User

When not to use a generic#

The most common mistake is reaching for one where a plain type would do:

typescript
function log<T>(message: T): void {           // pointless — T is never used
  console.log(message);
}
function log(message: string): void {}        // just say what you mean

A type parameter earns its place only when it appears at least twice — usually once in a parameter and once in the return type, so it can carry information between them. If it appears once, delete it.

Generated code over-reaches here regularly, producing elaborate generic signatures for functions that take one concrete type. Push back towards the simple version.

Exercise#

typescript
// Write a generic function `groupBy` that:
//   - takes an array of T and a key K (a key of T whose value is a string)
//   - returns Record<string, T[]>
// Type it so that groupBy(users, "role") compiles and groupBy(users, "nope") does not.

// write your code here

Common questions#

When should I add a type parameter?#

When the same type needs to appear in more than one place in the signature — a parameter and the return, or two parameters that must match. If it appears only once, it is doing nothing and a concrete type is clearer.

What does extends mean here — is it inheritance?#

No. In a generic constraint it means "assignable to", so T extends { length: number } reads as "T must have at least a numeric length". It is a bound on what may be substituted, not a class relationship.

Why is my inferred type wider than I wanted?#

Usually because the argument was inferred loosely — a string rather than a literal. as const on the argument, or a T extends string constraint, keeps the narrow literal type instead of widening it.

Get the TypeScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.