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
Kand values areV: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.
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 }.
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.
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
Group by something else
In the last example call
groupBy(people, (p) => p.name.length). What isKnow? (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 saysnumber. - 2
Add an event
In the emitter example add
search: { query: string }toAppEventsand write anon("search", ...)handler. Hover (or// ^?) the parameter: it is already typed. - 3
Cache with text keys
Change
new Cache<number, number>()tonew Cache<string, number>()and fix the calls. Which lines break and why?
Code & diagrams
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/missingThe 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 5The 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 timeK 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.
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.
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
handlersfield uses a mapped type ({ [K in keyof Events]?: ... }, Topic 8.4). Writing the field asRecord<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 Vin the cache is a safe cast:has(key)just proved there is an entry. ButVitself may includeundefined(Cache<string, number | undefined>), sogetreturningundefinedis ambiguous. Real caches return a wrapper ({ hit: true; value }) or usehasfirst, asgetOrComputedoes. - ▸
With
exactOptionalPropertyTypeson,Partial<Record<K, T[]>>forbids explicitly assigningundefinedto a key, which suits a grouping function: a key is either absent or has an array.
Remember this
- 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 functionfetchJson<T>(url): Promise<T>lets the caller say what the server sends:await fetchJson<User>("/api/user")is aUser. Be honest about what it does: the compiler can't look at the network. Theas Tinside the helper is a promise made by you, not a check. If the server sends{ msg: 42 }the program will still run with a wrongUserand crash later. Without a type argument you getunknown, which forces a check. Topic 13.4 shows how to validate the data for real. - 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]). Nowemit("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'sK extends keyof Tdoing real work. - 3
A generic cache.
Cache<K, V>wraps aMap<K, V>and offersget,setandgetOrCompute(key, compute). One class servesCache<number, number>for squares andCache<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
Typed array helpers.
unique<T>(items: readonly T[]): T[]andchunk<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:Tis the item type,Kis whatever the key function returns (string,numberorsymbol;PropertyKeyis that union), and the result is an object from keys to arrays of items. The callback's return type teaches the compiler whatKis, exactly likemapin Topic 7.2. - 5
Use **
readonly T[]** for input parameters of helpers that only read the array. A mutableT[]parameter would refuse areadonlyarray (Topic 5.1), whilereadonly T[]accepts both. Return a freshT[]if the caller may want to change the result. - 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
fetchJsonit's the response, in the emitter it's the payload chosen by the name, in the cache it is the key and value, ingroupByit's the item and the key. If you can't say what links two uses ofT, you probably don't need a generic. - 7
Watch for generics that lie.
fetchJson<User>andJSON.parse(text) as Userboth 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
How would you type a function fetchJson? What does the type guarantee, and what doesn't it?
How does a typed event emitter link the event name to its payload?
Why is the result of groupBy a Partial<Record<K, T[]>> and not a Record<K, T[]>?
What is the downside of as T inside a generic helper and how do you limit it?
Practice
Write compact<T>(items: (T | null | undefined)[]): T[] that removes null and undefined, using a type predicate so the result type is T[].
Write countBy<T, K extends PropertyKey>(items: readonly T[], keyOf: (item: T) => K): Partial<Record<K, number>> and count words by their length.
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 ofMap<K, V>.I can write
groupByand explainK extends PropertyKeyand why the result isPartial.I can use
readonly T[]for helper parameters that only read.