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
- 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(", "));
- 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
-
Now call
pluck(days, "khw")and compile:TS2345, because"khw"is not inkeyof DailyYield. Then changegroupByto useMap<string, T[]>internally and watch the type checker lose the link between the key function's return type and the map's key type. -
Finally, add a second collection with a different shape — say
{ inverter: string; kwh: number }[]— and confirm thatpluckandgroupBywork unchanged. That reuse, with no casts, is the whole point.
Key takeaways
- A generic keeps the caller's type;
anythrows 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 TwithT[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>andRepository<T>are the generic shapes worth memorising, andAwaited<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
- What exactly does the caller lose when a function takes
any[]and returnsany? - Why is
get(object, key)written asT[K]rather thanT[keyof T], and what wouldT[keyof T]give back? firstOr(items, 0)returnsT | D. When would one type parameter have been the better signature?- You need
sumByfor 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?