Skip to content

09 — Generics

Level: Beginner · Time: ~35 min · Prerequisites: Lesson 8 — Narrowing and Type Guards


Why this matters

Sooner or later you write a function that does the same thing to whatever you hand it: read the first element, group a list by a field, wrap a success or a failure, store and retrieve by key. The quick way is to type the parameter any, and the moment you do, the compiler stops helping at every call site — the caller's type disappears into the function and comes back untyped. A generic keeps that type instead: the function is written once, and each call gets an answer in the caller's own type. Generics are the mechanism behind Array<T>, Promise<T>, Map<K, V> and almost every library you use, and they are cheap to write once the two rules are clear — the type travels with the value, and a constraint says what the type must be able to do.


The problem generics solve

The any version compiles and tells the caller nothing; the generic version keeps the type at the call site.

type DailyYield = { date: string; kwh: number };

function firstAny(items: any[]): any {          // the caller's type is lost
  return items[0];
}

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

const days: DailyYield[] = [{ date: "2024-06-01", kwh: 21.4 }];
const day = first(days);        // DailyYield | undefined, inferred from the argument
const loose = firstAny(days);   // any: hover the variable and there is nothing to see
console.log(day?.kwh, loose);

T is a placeholder for whatever the caller passes, so first(days) returns DailyYield | undefined and first(["a", "b"]) returns string | undefined from the same code. Nothing is asserted and nothing is cast: the type is inferred from the argument.

Inference, several parameters and defaults

The compiler infers each type parameter from the arguments, which is why call sites need no angle brackets. A second parameter can carry a fallback, and keyof keeps a property name honest.

function firstOr<T, D>(items: readonly T[], fallback: D): T | D {
  return items[0] ?? fallback;
}

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

const empty: number[] = [];
console.log(firstOr(empty, 0), firstOr(days, null));   // number, DailyYield | null
console.log(pluck(days, "kwh"));                       // number[]
// pluck(days, "kwha");                                // ❌ TS2345: not a key of DailyYield

Two type parameters that never interact are usually a sign the function is doing two jobs, but firstOr is the honest case: the collection type and the fallback type are genuinely independent, and the return type says T | D rather than collapsing both to unknown.

Constraints: extends, keyof and indexed access

A constraint states what the type must support, so the body can do more while the caller keeps the specific type.

type DailyYield = { date: string; kwh: number };
const firstDay: DailyYield = { date: "2024-06-01", kwh: 21.4 };

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

function sum<T extends number>(values: readonly T[]): number {
  return values.reduce((total, value) => total + value, 0);
}

console.log(get(firstDay, "date"), get(firstDay, "kwh"));
console.log(sum([21.4, 18.9]));
// get(firstDay, "kwhh");   // ❌ TS2345: '"kwhh"' is not assignable to 'keyof DailyYield'
// sum(["21.4"]);           // ❌ TS2345: 'string' does not satisfy the constraint 'number'

T[K] is an indexed access type: "the type of the property K on T". Without the K extends keyof T constraint, object[key] would be an error; with it, get(firstDay, "date") is a string and get(firstDay, "kwh") is a number. Inside sum, the constraint is what makes total + value legal — try the same body with an unconstrained T and the compiler refuses with TS2365.

Generic interfaces, classes and result types

Generic shapes are how libraries express containers and outcomes. Use T for one type, K and V for a key and a value, and a name such as TItem or TResult when a single letter stops being clear.

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

type Paginated<T> = { items: T[]; page: number; pageSize: number; total: number };

interface Repository<T, Id = string> {
  find(id: Id): Promise<T | undefined>;
  list(): Promise<T[]>;
  save(entity: T): Promise<void>;
}

class MemoryCache<T> {
  private readonly items = new Map<string, T>();
  get(key: string): T | undefined { return this.items.get(key); }
  set(key: string, value: T): void { this.items.set(key, value); }
}

type Day = { date: string; kwh: number };
const cache = new MemoryCache<Day>();
cache.set("2024-06-01", { date: "2024-06-01", kwh: 21.4 });
console.log(cache.get("2024-06-01")?.kwh);

E defaults to Error, so Result<DailyYield> reads well and Result<DailyYield, "network"> is still available when the failure is a domain value. Repository<T, Id = string> is a contract a class or an object literal can satisfy, and classes are covered properly in Lesson 13 — what matters here is that T is fixed once, at the declaration of each implementation.

Generics with promises and pipelines

Async functions widen the same way: a loader of T becomes a promise of T, and a list of loaders becomes a promise of an array of T — without you writing that down.

type Loader<T> = () => Promise<T>;

async function loadAll<T>(loaders: readonly Loader<T>[]): Promise<T[]> {
  return Promise.all(loaders.map((load) => load()));
}

const loadJune = async (): Promise<DailyYield> => ({ date: "2024-06-01", kwh: 21.4 });
const loadJuly = async (): Promise<DailyYield> => ({ date: "2024-07-01", kwh: 23.8 });
type OneDay = Awaited<ReturnType<typeof loadJune>>;    // DailyYield

async function main(): Promise<void> {
  const days = await loadAll([loadJune, loadJuly]);    // DailyYield[]
  const june: OneDay = await loadJune();
  console.log(days.length, june.kwh);
}

main().catch((error: unknown) => console.error(error));

Awaited<T> unwraps a promise type, so Awaited<ReturnType<typeof loadJune>> is DailyYield even though the function is async. Types travel through the pipeline: map, filter and flatMap keep the element type, so one generic function at the top can serve every collection in the file.

When a generic is over-engineering

A type parameter that appears once in a signature buys nothing, and a type parameter three functions share is usually a design problem rather than a generic.

// The parameter appears once: the caller learns nothing, so write the concrete type
function wrap<T>(value: T): T[] { return [value]; }

// The rule of three: write it concretely twice, generalise the third time
function sumBy<T>(items: readonly T[], value: (item: T) => number): number {
  return items.reduce((sum, item) => sum + value(item), 0);
}
console.log(wrap(21.4), sumBy([{ kwh: 21.4 }, { kwh: 18.9 }], (row) => row.kwh));

Concrete types are readable, greppable and quick to change; a generic makes a promise about all future callers, and that promise has to be maintained. If a parameter is T on the way in and T on the way out with no constraint in between, write the concrete type. If two shapes need the same behaviour today, a union is often the honest answer.


Common mistakes

Mistake What happens The fix
A type parameter used once The signature is harder to read and the caller gains nothing Use the concrete type
Reaching for any inside a generic body The type parameter becomes decoration: nothing is checked Keep T and narrow inside the body
Using T without a constraint in arithmetic TS2365 on +: the operator is not defined for an unknown type Constrain it: T extends number
Typing a lookup as T[keyof T] Every property read becomes a union of all of them K extends keyof T and return T[K]
T, T1, T2 for everything The signature stops explaining the relationship Use T, K, V, or TItem, TResult
Generalising before the second case exists Abstract code guessing at requirements it has never seen Write it concrete twice; generalise on the third

Try it

  1. In the project from Lesson 2, create src/generics.ts:
type DailyYield = { date: string; kwh: number; site: string };
const days: DailyYield[] = [
  { date: "2024-06-01", kwh: 21.4, site: "roof-east" },
  { date: "2024-06-02", kwh: 18.9, site: "roof-west" },
  { date: "2024-06-03", kwh: 24.1, site: "roof-east" },
  { date: "2024-06-04", kwh: 0, site: "roof-west" },
];
function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}
function groupBy<T, K extends string>(items: readonly T[], key: (item: T) => K): Map<K, T[]> {
  const groups = new Map<K, T[]>();
  for (const item of items) {
    const k = key(item);
    groups.set(k, [...(groups.get(k) ?? []), item]);
  }
  return groups;
}

const bySite = groupBy(days, (day) => day.site);
for (const [site, entries] of bySite) {
  const total = entries.reduce((sum, entry) => sum + entry.kwh, 0);
  console.log(`${site}: ${total.toFixed(1)} kWh over ${entries.length} days`);
}
console.log(pluck(days, "date").join(", "));
  1. Compile and run it:
npx tsc --target ES2023 --lib ES2023 --module NodeNext --moduleResolution NodeNext --outDir dist src/generics.ts
node dist/generics.js

Expected output:

roof-east: 45.5 kWh over 2 days
roof-west: 18.9 kWh over 2 days
2024-06-01, 2024-06-02, 2024-06-03, 2024-06-04
  1. Now call pluck(days, "khw") and compile: TS2345, because "khw" is not in keyof DailyYield. Then change groupBy to use Map<string, T[]> internally and watch the type checker lose the link between the key function's return type and the map's key type.

  2. Finally, add a second collection with a different shape — say { inverter: string; kwh: number }[] — and confirm that pluck and groupBy work unchanged. That reuse, with no casts, is the whole point.


Key takeaways

  • A generic keeps the caller's type; any throws it away, and every call site loses the autocompletion and the checks that go with it.
  • The compiler infers type parameters from the arguments, so call sites usually need no angle brackets.
  • K extends keyof T with T[K] is the pair that makes a typed lookup possible; a constraint exists to make the body legal, not to restrict the caller.
  • Result<T, E>, Paginated<T> and Repository<T> are the generic shapes worth memorising, and Awaited<T> unwraps a promise type when you need it.
  • A type parameter that appears once buys nothing — write it concretely twice before generalising.

Check yourself

  1. What exactly does the caller lose when a function takes any[] and returns any?
  2. Why is get(object, key) written as T[K] rather than T[keyof T], and what would T[keyof T] give back?
  3. firstOr(items, 0) returns T | D. When would one type parameter have been the better signature?
  4. You need sumBy for a third collection type next week, and you have two working concrete versions today. What does the rule of three tell you to do now?

Next

Lesson 10 — Interfaces and Type Aliases