← Concepts & practices
Concept Data modeling and type design

When a generic earns its place

Connect two types, or don’t add one.

You already rely on array.map knowing what it returns and await knowing what a promise holds. Let’s follow a workout tracker whose saved data comes back as any, until a typo compiles and last year’s saves show “NaN reps”.

TypeScriptGo One workout log, two implementations.

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
workouts.ts
// 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.

TypeScriptReading
workouts.ts
// 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;
}
GoAlongside
workouts.go
// 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.

Generics

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.

01/ 05
Load the week and summarize it

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
maxBy replaced heaviestSet and longestRun.
Types that survive the trip
Pass sets, get SetEntry | undefined back, not any.
Typos caught
saved[0].rep is 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 null from 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.

What does “Total reps” show?

To get rid of any, someone writes loadAs<T>(store, key): T | null, which returns JSON.parse(raw) as T. The history screen calls totalReps(loadAs<SetEntry[]>(store, 'sets') ?? []). A person opens the app with last year’s save, where each set has rep instead of reps.

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.

Select

Options and the change

onChange receives one of the options, typed.

readStored

Parser and result

The value is whatever the parser produces, or the fallback.

oneOf

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.

stored.ts
// 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.

ReactAlready in your code
Select.tsx
// 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.

Familiar generic functions and the values their type parameters connect
Where you’ve seen itWhat the type connects
array.map(fn)What fn returns and what the new array holds
await promiseWhat the promise resolves to and what await gives you
Go’s slices.IndexFuncThe slice’s element type and the callback’s parameter
Go’s maps.KeysThe 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.

How a change affects the any version with copied helpers and the generic version
The changeany and copiesType parameters
Add a swim logAnother copy of the loop.maxBy(swims, …).
Rename repsTypos compile; old saves show NaN.Every typo is an error; old saves are null.
Add a saved settingA new string key, anywhere.A key in Saved and a parser.
Read JSON once in a scriptFine.More signature than script.
A helper for one type, used onceFine.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

Take the tracker into your editor. Replace loadAs with loadSaved on the history screen, add a swim log using maxBy, and delete labelOfGeneric.

Back to Concepts & practices →