Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~29 min· topic 5 of 7

Topic 7.5

Real-World Generic Patterns

In one line

Four generics you'll meet in real projects: a typed fetchJson<T>, an event emitter whose payloads depend on the event name, a generic cache and the array helpers groupBy, unique and chunk.

Think of it like this

A school's lost-and-found shelf with labelled boxes: "jackets", "lunchboxes", "water bottles". Whatever you put into the "jackets" box comes out as a jacket. The shelf is one design, but each box has its own contents type. Real generics are these labelled boxes: one helper, and the label (the type argument) decides what goes in and what comes out.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Event emitter
An object that lets code announce named events and lets other code listen for them.
Payload
The data that comes with an event, like { user: "Asha" } for a login.
Cache
A store of results kept so they don't have to be computed or fetched again.
PropertyKey
A built-in type that means string | number | symbol: anything that can be an object key.
groupBy
A helper that sorts a list into labelled piles, one per key.
Type assertion (as T)
A promise to the compiler that a value has a type. It is not checked.
readonly parameter
A parameter typed readonly T[] says the function only reads the array and won't change it.
Record
A built-in type for an object whose keys are K and values are V: Record<string, number>.

Step by step

01fetchJson: the caller says what comes back

The helper takes a URL and returns Promise<T>. T appears only in the return type, so inference has nothing to read: the caller must write it (fetchJson<User>), or get unknown.

The as T is the honest weak point. The compiler trusts it. Keep this cast in exactly one place.

fetch-json.tswhole filets
async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
  return (await response.json()) as T; // a promise, not a check
}
const user = await fetchJson<User>("/api/user");

02An emitter that links the name to the payload

Events is one object type that maps names to payloads. on and emit take K extends keyof Events and use Events[K] for the payload. So when the name is "login", the payload type is { user: string }, and for "cartChanged" it's { items: number; total: number }.

An emitter that links the name to the payloaddiagram
Rendering diagram…

03A cache is a generic wrapper around Map

Cache<K, V> passes its own K and V straight into Map<K, V>. getOrCompute is the useful method: look in the store first, call the function only on a miss.

04groupBy: item type and key type are separate

T comes from the array, K comes from what the key function returns. Grouping people by city gives K = string. Grouping words by length gives K = number.

The result is Partial<Record<K, T[]>>: an object where each key may or may not exist. Partial is honest: not every possible key has a pile. You'll build Record and Partial yourself in Topics 8.4 and 10.1.

group-by.tswhole filets
function groupBy<T, K extends PropertyKey>(
  items: readonly T[],
  keyOf: (item: T) => K,
): Partial<Record<K, T[]>> {
  const out: Partial<Record<K, T[]>> = {};
  for (const item of items) {
    (out[keyOf(item)] ??= []).push(item);
  }
  return out;
}

Try it yourself

  1. 1

    Group by something else

    In the last example call groupBy(people, (p) => p.name.length). What is K now? (number.) Look at the printed object: the keys are written as '4' and '5', because JavaScript object keys are always text at runtime, although the type says number.

  2. 2

    Add an event

    In the emitter example add search: { query: string } to AppEvents and write an on("search", ...) handler. Hover (or // ^?) the parameter: it is already typed.

  3. 3

    Cache with text keys

    Change new Cache<number, number>() to new Cache<string, number>() and fix the calls. Which lines break and why?

Code & diagrams

A typed fetchJson (with a fake network so it runs anywhere)

With <User> the result is typed and user.name has autocomplete. Without a type argument T becomes unknown, and the compiler stops you from using it before checking (line 27). Note the data was never checked: the type came from you.

Console

(nothing was printed)

Expected console output

ASHA
Asha
HTTP 404 for /api/missing
A typed event emitter

The handler parameter e needs no annotation: it comes from the event name. Line 28 sends the wrong payload and line 29 misspells the event. The mapped type in handlers is a preview of Topic 8.4.

Console

(nothing was printed)

Expected console output

hello Asha
2 items, 599
hello 5
A generic cache

The second getOrCompute finds the stored value, so slowSquare runs once. Keys and values stay consistent: a text key in a Cache<number, number> is rejected.

Console

(nothing was printed)

Expected console output

144
144
computed 1 time
groupBy, unique and chunk

K comes from what the arrow function returns (string for p.city). The result is a Partial Record: a city may have no pile, so reading byCity.Pune gives T[] | undefined.

Console

(nothing was printed)

Expected console output

{ Pune: [ { name: 'Asha', city: 'Pune' }, { name: 'Meera', city: 'Pune' } ], Delhi: [ { name: 'Ravi', city: 'Delhi' } ] }
[ 3, 1, 2 ] [ [ 'a', 'b' ], [ 'c', 'd' ], [ 'e' ] ]

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Typo in an event name

In the emitter example, bus.emit("logIn", ...) has a capital I.

terminal
$ Types panel
── what you'll see ──
index.ts(29,10): error TS2345: Argument of type '"logIn"' is not assignable to parameter of type 'keyof AppEvents'.

Break #2

Use an interface as a string-key constraint

Make the emitter's constraint Events extends Record<string, unknown> and keep AppEvents as an interface.

terminal
$ Types panel
── what you'll see ──
index.ts(23,25): error TS2344: Type 'AppEvents' does not satisfy the constraint 'Record<string, unknown>'.
Index signature for type 'string' is missing in type 'AppEvents'.

Myth vs fact

Myth

fetchJson<User>() checks that the server really sent a User.

Fact

It checks nothing. Types are erased and the compiler can't see the network. The as T is a promise from you. Validate untrusted data (Topic 13.4) when being wrong would hurt.

Myth

A bigger generic with more type parameters is always more reusable.

Fact

Each type parameter is something every reader must understand. Add one only when it links two uses. Plain functions are easier to read and often better.

Myth

groupBy returns arrays for all keys.

Fact

Only for the keys that appeared. That is why the result is a Partial<Record<K, T[]>>, and result.someKey is T[] | undefined.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The emitter's handlers field uses a mapped type ({ [K in keyof Events]?: ... }, Topic 8.4). Writing the field as Record<string, Handler<any>[]> would compile too, but it throws away the link between name and payload and needs casts inside. Keep the precise type in the storage and the casts disappear.

  • ▸

    this.store.get(key) as V in the cache is a safe cast: has(key) just proved there is an entry. But V itself may include undefined (Cache<string, number | undefined>), so get returning undefined is ambiguous. Real caches return a wrapper ({ hit: true; value }) or use has first, as getOrCompute does.

  • ▸

    With exactOptionalPropertyTypes on, Partial<Record<K, T[]>> forbids explicitly assigning undefined to a key, which suits a grouping function: a key is either absent or has an array.

Remember this

  1. 1

    The problem this solves: Generics are easy to understand alone but hard to use on real tasks, such as network calls or lookups, where the right type isn't obvious. **Typed fetchJson<T>.** Network code is the first place beginners meet generics. A function fetchJson<T>(url): Promise<T> lets the caller say what the server sends: await fetchJson<User>("/api/user") is a User. Be honest about what it does: the compiler can't look at the network. The as T inside the helper is a promise made by you, not a check. If the server sends { msg: 42 } the program will still run with a wrong User and crash later. Without a type argument you get unknown, which forces a check. Topic 13.4 shows how to validate the data for real.

  2. 2

    A typed event emitter. An emitter lets one part of a program announce things ("the user logged in") and other parts listen. The usual JavaScript version has emit(name: string, data: any), so nothing checks that the data matches the event. The generic version takes a map from event names to payload types, Emitter<AppEvents>, and links the two arguments: emit<K extends keyof Events>(name: K, payload: Events[K]). Now emit("login", { user: "Asha" }) is checked, a typo in the name is an error, and a listener's payload is typed with no annotation. This is Topic 7.3's K extends keyof T doing real work.

  3. 3

    A generic cache. Cache<K, V> wraps a Map<K, V> and offers get, set and getOrCompute(key, compute). One class serves Cache<number, number> for squares and Cache<string, User> for profiles. The type parameters keep keys and values consistent: you can't store a string under a numeric cache's key. Inside, Map<K, V> is itself a generic class from the standard library, so your type parameters flow straight into it.

  4. 4

    Typed array helpers. unique<T>(items: readonly T[]): T[] and chunk<T>(items, size): T[][] are simple: the element type goes in and comes out. groupBy<T, K extends PropertyKey>(items, keyOf: (item: T) => K) is the interesting one: T is the item type, K is whatever the key function returns (string, number or symbol; PropertyKey is that union), and the result is an object from keys to arrays of items. The callback's return type teaches the compiler what K is, exactly like map in Topic 7.2.

  5. 5

    Use **readonly T[]** for input parameters of helpers that only read the array. A mutable T[] parameter would refuse a readonly array (Topic 5.1), while readonly T[] accepts both. Return a fresh T[] if the caller may want to change the result.

  6. 6

    The pattern behind all four: find the thing that varies, make it a type parameter, and tie it to the places it shows up. In fetchJson it's the response, in the emitter it's the payload chosen by the name, in the cache it is the key and value, in groupBy it's the item and the key. If you can't say what links two uses of T, you probably don't need a generic.

  7. 7

    Watch for generics that lie. fetchJson<User> and JSON.parse(text) as User both turn an unchecked value into a trusted type. The type is only as true as the code that guarantees it. Good practice is to keep the cast in one place (the helper), and name it clearly, so a future change to validation touches one function.

Explain it without notes

01

How would you type a function fetchJson? What does the type guarantee, and what doesn't it?

02

How does a typed event emitter link the event name to its payload?

03

Why is the result of groupBy a Partial<Record<K, T[]>> and not a Record<K, T[]>?

04

What is the downside of as T inside a generic helper and how do you limit it?

Practice

01

Write compact<T>(items: (T | null | undefined)[]): T[] that removes null and undefined, using a type predicate so the result type is T[].

02

Write countBy<T, K extends PropertyKey>(items: readonly T[], keyOf: (item: T) => K): Partial<Record<K, number>> and count words by their length.

03

Write a once<T>(fn: () => T): () => T helper that calls fn only the first time and returns the stored result afterwards.

Trade-offs

  • ↔

    Typed helpers (fetchJson<T>) make call sites look safe while hiding an unchecked cast. The trade is convenience against honesty: for internal APIs you control it is fine, for third-party data add a runtime check.

  • ↔

    A custom emitter or cache is a few lines, but mature libraries handle edge cases (memory limits, async errors, unsubscribe). Write your own to learn and to keep types tight in small apps; use a library for production-critical behaviour and wrap it in the same typed API.

Done when you can

  • I can write fetchJson<T> and say what it does not guarantee.

  • I can write an emitter where emit(name, payload) links the name to the payload type.

  • I can write a Cache<K, V> on top of Map<K, V>.

  • I can write groupBy and explain K extends PropertyKey and why the result is Partial.

  • I can use readonly T[] for helper parameters that only read.