01 / The idea
Storage that returns any is a fair start.
You’re building a workout tracker that saves strength sets to localStorage. load(store, key) parses whatever was saved, and totalReps and heaviestSet summarize the week. JSON.parse returns any anyway, so the first version passes that along. It’s short, and the week’s summary
is right.
Read the first versionTypeScript · the version this lesson starts from
// The first version: storage hands back whatever was saved, and each summary is written for one log.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- this version returns any on purpose
export function load(store: KeyValueStore, key: string): any {
const raw = store.getItem(key);
return raw === null ? null : JSON.parse(raw);
}
export function totalReps(sets: SetEntry[]): number {
let total = 0;
for (const set of sets) total += set.reps;
return total;
}
export function heaviestSet(sets: SetEntry[]): SetEntry | undefined {
let best: SetEntry | undefined;
for (const set of sets) if (!best || set.kg > best.kg) best = set;
return best;
}
export function longestRun(runs: RunEntry[]): RunEntry | undefined {
let best: RunEntry | undefined;
for (const run of runs) if (!best || run.km > best.km) best = run;
return best;
} Go’s first version unmarshals into any and reads fields with type assertions.
Both languages meet again at maxBy in section 02.
Then the tracker adds runs, and longestRun arrives as a copy of heaviestSet with different types. Someone writes saved[0].rep, and
it compiles. And last year’s version of the app saved rep instead of reps, so people with old saves see “NaN reps”.
A type parameter earns its place when it connects the types of two or more values: the items you pass in and the item you get back, or the key you ask for and the value it holds. If it connects nothing, it’s decoration. If it only names a return type, it’s a cast with a nicer name. The TypeScript handbook says it in one line: “type parameters are for relating the types of multiple values”.
Section 05 builds a typed select and a stored filter, in React and Svelte.
02 / See the shape
Let the type flow from what goes in to what comes out.
The basic form is maxBy, sumBy, and groupBy, which
work for sets, runs, or anything else. In the wild is saved data where the
key decides the type, with a parser per key. At the call site loads a week and
an old save.
Both languages produce the same summary, and both refuse the old save.
maxBy, sumBy, and groupBy: the type of the items you pass comes back out in the result, for sets, runs, or anything else.
// T appears in the items and in the result: whatever the array holds comes back out.
export function maxBy<T>(items: readonly T[], score: (item: T) => number): T | undefined {
let best: T | undefined;
let bestScore = -Infinity;
for (const item of items) {
const value = score(item);
if (value > bestScore) {
best = item;
bestScore = value;
}
}
return best;
}
export function sumBy<T>(items: readonly T[], value: (item: T) => number): number {
return items.reduce((sum, item) => sum + value(item), 0);
}
// K relates the keys the callback produces to the keys of the result.
export function groupBy<T, K extends string>(
items: readonly T[],
keyOf: (item: T) => K
): Partial<Record<K, T[]>> {
const groups: Partial<Record<K, T[]>> = {};
for (const item of items) (groups[keyOf(item)] ??= []).push(item);
return groups;
} // MaxBy works on a slice of any element type, and returns that same type.
func MaxBy[T any](items []T, score func(T) float64) (T, bool) {
var best T
for i, item := range items {
if i == 0 || score(item) > score(best) {
best = item
}
}
return best, len(items) > 0
}
func SumBy[T any](items []T, value func(T) float64) float64 {
total := 0.0
for _, item := range items {
total += value(item)
}
return total
}
// GroupBy relates the key the callback returns to the map's key type.
func GroupBy[T any, K comparable](items []T, keyOf func(T) K) map[K][]T {
groups := map[K][]T{}
for _, item := range items {
key := keyOf(item)
groups[key] = append(groups[key], item)
}
return groups
} Reading the TypeScriptConstraints and indexed access
K extends keyof Saved limits the key to 'sets', 'runs', or 'units', and Saved[K] looks up what that
key holds. The handbook calls this an indexed access type: a way “to look up a specific property
on another type”.
The parsers are a mapped type, so each one must return the right type for its key. JSON.parse still returns any; the parser is where it stops.
Reading the GoType parameters and typed keys
MaxBy[T any] works on a slice of any element type. Go has no keyof, so Key[T] carries the type instead: SetsKey is a Key[[]SetEntry], and LoadSaved returns a []SetEntry for it.
Go fails differently from TypeScript. Through any and type assertions, the
old save reads as 0 reps, not NaN. LoadSaved uses DisallowUnknownFields and refuses it.
03 / Follow the types
Watch what each call returns, and what TypeScript knew about it.
Five steps. The values come from running the lesson’s functions; the types and compiler verdicts are TypeScript’s own output for those lines, checked by a test. Before each step, guess the type on the right.
In Try it, run each call against this week’s save and last year’s.
What does this type parameter connect?
Storage hands back any. load(store, 'sets') gives 5 sets, typed any. totalReps(saved) gives 29, typed number. heaviestSet(sets) gives Deadlift 3 × 140 kg, typed SetEntry | undefined. longestRun(runs) gives 8.2 km in 47 min, typed RunEntry | undefined. It works. heaviestSet and longestRun are the same loop with different types, and the saved data is any.
A fair first version.
load returns any, and each summary is written for one log.
Reduced motion: choose a scene to see its completed state.
Read this scene
load returns any, and each summary is written for one log.
Storage hands back any. load(store, 'sets') gives 5 sets, typed any. totalReps(saved) gives 29, typed number. heaviestSet(sets) gives Deadlift 3 × 140 kg, typed SetEntry | undefined. longestRun(runs) gives 8.2 km in 47 min, typed RunEntry | undefined. It works. heaviestSet and longestRun are the same loop with different types, and the saved data is any.
Watch restarts when you return. Step through keeps your selected step. Try it starts with the total through any and last year’s save each time you open it.
What a type parameter that relates values buys you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- One function for every log
maxByreplacedheaviestSetandlongestRun.- Types that survive the trip
- Pass sets, get
SetEntry | undefinedback, notany. - Typos caught
saved[0].repis error TS2551, with “Did you mean 'reps'?”.- Keys that can’t drift
loadSaved(store, 'weight')doesn’t compile.- Data checked once, at the edge
- An old save comes back as
nullfrom one place, not NaN everywhere.
The review words are type parameter (T), type argument (what fills it in), inference for TypeScript
working T out from sets, constraint for K extends keyof Saved, parametric polymorphism for one piece
of code serving many types, and type assertion for the cast as T that checks nothing. Section 08 covers what they cost.
04 / Try a decision
A generic that only casts.
To get rid of any, someone gave load a type parameter. The code is
in load-as.ts, and the lesson’s tests pin what happens.
05 / Give it a real job
A select and a saved filter that keep their types.
In the real app, the history screen has a range filter (this week, month, or year) that
survives a reload, and an exercise picker. Handlers should receive the option they picked,
not a string to look up again, and an edited localStorage value shouldn’t leak through as any.
Options and the change
onChange receives one of the options, typed.
Parser and result
The value is whatever the parser produces, or the fallback.
Strings and the type
oneOf(['week', 'month', 'year']) returns exactly those.
The example leaves out syncing between tabs, migrations of old saves, and styling the select.
Build UIs?Every reusable component that takes items and hands one back is a place where a type parameter either earns its place or loses the type.
Where it already is in your components
A select, a list, or a combobox takes items and hands one back. In React a component is a
function, so Select<T> is a generic function, and TypeScript infers T from options. The exercise picker’s handler receives an Exercise, so exercise.muscle needs no cast.
Svelte puts the type parameter on the script tag. Its docs: “Components can declare a
generic relationship between their properties.” and “The content of generics is what you would put between the <...> tags of a generic
function.”
When you have to own it
Now it’s the saved filter. readStored’s T connects the parser,
the fallback, and the result, so the range is 'week' | 'month' | 'year'. A value someone edited in dev tools falls back to 'week' instead of reaching the chart as any.
A useStored<T>(key) with no parser would look just as tidy. Its T would appear only in the result, like loadAs.
// Reading and writing a saved setting. T ties the parser, the fallback, and the result together:
// what you get back is whatever the parser can produce, or the fallback of the same type.
export type Parse<T> = (value: unknown) => T | null;
export function readStored<T>(
storage: Pick<Storage, 'getItem'>,
key: string,
parse: Parse<T>,
fallback: T
): T {
const raw = storage.getItem(key);
if (raw === null) return fallback;
try {
return parse(JSON.parse(raw)) ?? fallback;
} catch {
return fallback;
}
}
export function writeStored<T>(storage: Pick<Storage, 'setItem'>, key: string, value: T): void {
storage.setItem(key, JSON.stringify(value));
}
// A parser that accepts one of a fixed list of strings, typed as exactly those strings.
export function oneOf<const Options extends readonly string[]>(
options: Options
): Parse<Options[number]> {
return (value) =>
typeof value === 'string' && (options as readonly string[]).includes(value)
? (value as Options[number])
: null;
}
A select whose options and change handler share a type, used to pick an exercise without a lookup or a cast.
// T relates the options you pass in to the option onChange hands back.
export function Select<T>({
id,
options,
value,
getKey,
getLabel,
onChange
}: {
id: string;
options: readonly T[];
value: T;
getKey: (option: T) => string;
getLabel: (option: T) => string;
onChange: (option: T) => void;
}) {
return (
<select
id={id}
value={getKey(value)}
onChange={(event: { target: { value: string } }) => {
const next = options.find((option) => getKey(option) === event.target.value);
if (next !== undefined) onChange(next);
}}
>
{options.map((option) => (
<option key={getKey(option)} value={getKey(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
type Exercise = { id: string; name: string; muscle: 'legs' | 'chest' | 'back' };
export function ExercisePicker({
exercises,
value,
onPick
}: {
exercises: readonly Exercise[];
value: Exercise;
onPick: (exercise: Exercise) => void;
}) {
// onChange receives an Exercise, so exercise.muscle is known here without a cast.
return (
<Select
id="exercise"
options={exercises}
value={value}
getKey={(exercise) => exercise.id}
getLabel={(exercise) => `${exercise.name} (${exercise.muscle})`}
onChange={onPick}
/>
);
}
06 / Recognize it elsewhere
Ask what each type parameter connects.
You’ve used all of these. For each one, name the two values that share a type.
| Where you’ve seen it | What the type connects |
|---|---|
array.map(fn) | What fn returns and what the new array holds |
await promise | What the promise resolves to and what await gives you |
Go’s slices.IndexFunc | The slice’s element type and the callback’s parameter |
Go’s maps.Keys | The map’s key type and the values its iterator yields |
JSON.parse(text) | Nothing, which is why it returns any |
Go’s signature spells it out: func IndexFunc[S ~[]E, E any](s S, f func(E) bool) int. E appears in the slice and in the callback, so the callback gets the element type.
07 / Already in your toolbox
Both languages already say when to reach for one.
Three places to look. For each one, find the rule it gives for leaving a generic out.
TypeScript · Guidelines for writing good generic functions
Push type parameters down, use fewer of them, and reconsider any that appear only once.
Read the handbook ↗Go blog · When To Use Generics
Ian Lance Taylor on containers, identical implementations, and when an interface is the better tool.
Read the post ↗Svelte · Generic $props
How a component declares a type parameter that connects its properties.
Read the docs ↗A useful counterexample: calling a methodWhen an interface is enough
labelOf only reads exercise and reps. A parameter
typed with those two fields accepts a set, a planned set, or a template, and returns a
string. The Go blog: “If all you need to do with a value of some type is call a method on
that value, use an interface type, not a type parameter.”
08 / The parts to watch
A type parameter can hide as much as it shows.
These are the places it still goes wrong.
A type parameter only in the return type is a cast
loadAs<T>(): T lets the caller name any type and returns it unchecked.
The handbook: “If a type parameter only appears in one location, strongly reconsider if
you actually need it.” The linter flags any in load; it has nothing to say about as T.
Types don’t check data at run time
TypeScript’s types are gone when the code runs. Saved data, API responses, and URL
parameters need a parser, like loadSaved’s, before a type means anything.
A constraint can lose the type
The handbook’s rule is “When possible, use the type parameter itself rather than
constraining it”: first<T>(items: T[]) returns T, while first<T extends any[]>(items: T) returns any.
An extra type parameter is a red flag
A parameter that doesn’t relate two values makes callers who write type arguments supply one more for nothing. The handbook calls that “always a red flag”.
Inference can be wider than you meant
groupBy(sets, (set) => set.exercise) infers K as string, so the result is Partial<Record<string, SetEntry[]>>. The type is accurate, but it
can’t tell you which exercises exist.
Signatures are read more than written
<K extends keyof Saved> is worth reading once. Three constrained parameters
on a helper used in one place are a puzzle for every reviewer.
09 / Make the call
What would you have to change tomorrow?
Give both versions a plausible change and follow the work it creates.
| The change | any and copies | Type parameters |
|---|---|---|
| Add a swim log | Another copy of the loop. | maxBy(swims, …). |
Rename reps | Typos compile; old saves show NaN. | Every typo is an error; old saves are null. |
| Add a saved setting | A new string key, anywhere. | A key in Saved and a parser. |
| Read JSON once in a script | Fine. | More signature than script. |
| A helper for one type, used once | Fine. | A type parameter that connects nothing. |
Add a type parameter when the same code serves several types and the caller needs the specific type back. A tracker with sets, runs, and saved settings is the moment.
Keep a concrete type, or an interface, when there’s one type or only a method call.
The question I’d leave beside the code is: which two values does this type parameter connect?
10 / Take the idea with you
Explain “NaN reps” without saying “generics.”
“Saved data came back untyped, so a renamed field slipped through and turned the total into NaN. Now the key we load decides the shape we expect, a parser checks it, and the summary functions hand back whatever type we give them.” In a review, the words are type parameter, constraint, and type assertion.
Before moving on, jot down why the cast compiled, what K connects in loadSaved, and one function in your own code whose type parameter appears only
once.
Connections to follow nextRelated lessons
- Polymorphism is the interface kind; this lesson is the parametric kind.
- Parse, don’t validate is what
loadSaved’s parsers are doing. - Branded and opaque types use the type system to keep look-alike values apart.
- Discriminated unions type values that come in several shapes.