Chapter 16 of 24 · TypeScript for Beginners — Complete Free Tutorial
Generics in Practice
Chapter 11 introduced generics as “types with parameters”; this chapter — 16 of a 24-chapter series — is where they pay rent. You will build a typed API client, a typed event emitter, a groupBy helper, a generic repository and a generic component, and after each one you will see the any version that compiles happily until production.
Where generics earn their keep
A generic earns its place when one implementation must serve several types while every call site stays precise: the shape is written once, mistakes surface where the data enters instead of three files away, and editors autocomplete real properties instead of any. The counter-example matters too — a function used once, with one concrete type, does not need a type parameter. Generics are a tool, not a ceremony.
A typed API client: fetchJson<T>
Every application fetches JSON. The generic version lets the caller name the expected shape; the any version returns a value that type-checks against everything and therefore protects nothing.
interface Lesson { id: string; title: string; minutes: number }
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`);
return (await res.json()) as T;
}
const lesson = await fetchJson<Lesson>('/api/lessons/1');
console.log(lesson.title.toUpperCase(), lesson.minutes);
// The any version: same body, opposite guarantees
async function fetchJsonAny(url: string): Promise<any> {
return (await fetch(url)).json();
}
const loose = await fetchJsonAny('/api/lessons/1');
console.log(loose.titel.toUpperCase()); // compiles fine, explodes later
Expected output: GENERICS 40 — while the any version prints nothing, crashing with TypeError: Cannot read properties of undefined (reading 'toUpperCase'). A typo in lesson fails to compile; loose.titel sails through tsc and dies in the browser. Chapter 18 builds the production-ready version of this helper.
A typed event emitter
Event systems are where any does the most damage, because payloads cross many files. Let the event map be the generic parameter and derive every handler type from it: each event name admits exactly one payload shape.
type Handler<T> = (payload: T) => void;
class Emitter<M extends Record<string, unknown>> {
private handlers: Partial<{ [K in keyof M]: Handler<M[K]>[] }> = {};
on<K extends keyof M>(event: K, handler: Handler<M[K]>): void {
const list = this.handlers[event] ?? [];
list.push(handler);
this.handlers[event] = list;
}
emit<K extends keyof M>(event: K, payload: M[K]): void {
for (const handler of this.handlers[event] ?? []) handler(payload);
}
}
type Events = { 'lesson:start': { id: string }; 'lesson:done': { id: string; score: number }; error: Error };
const emitter = new Emitter<Events>();
emitter.on('lesson:start', (p) => console.log('start', p.id));
emitter.emit('lesson:start', { id: 'ts-16' });
emitter.on('lesson:start', (p) => console.log(p.score)); // compile error
emitter.emit('lesson:start', { id: 123 }); // compile error
Expected output: start ts-16, followed by error TS2339: Property 'score' does not exist on type '{ id: string; }'. and error TS2322: Type 'number' is not assignable to type 'string'. — both caught where they are written. With an EventEmitter<any> both would compile, and the crash would land on the night the feature ships.
groupBy: one function, every key type
Two type parameters often appear together: one for the item type, one for whatever the key function produces. Constraining the key to string | number keeps the return type an honest Record.
function groupBy<T, K extends string | number>(
items: readonly T[],
key: (item: T) => K,
): Record<K, T[]> {
const out = {} as Record<K, T[]>;
for (const item of items) {
const k = key(item);
(out[k] ??= []).push(item);
}
return out;
}
interface Row { id: string; title: string; level: 'easy' | 'hard' }
const rows: Row[] = [
{ id: '1', title: 'Basics', level: 'easy' },
{ id: '2', title: 'Arrays', level: 'easy' },
{ id: '3', title: 'Generics', level: 'hard' },
];
const byLevel = groupBy(rows, (row) => row.level);
console.log(byLevel.easy.map((r) => r.title).join(', '), byLevel.hard.length);
byLevel.easyy; // with any: no error at all
Expected output: Basics, Arrays 1. Because K is inferred from your callback, byLevel is Record<'easy' | 'hard', Row[]> — so .easyy is a compile error, and renaming level in the interface breaks this line too, which is exactly the blast radius you want.
Repositories: generic stores with constraints
A store that works for any entity still needs the fields it depends on. A constraint like extends { id: string } declares that minimum, and Partial<T> from chapter 13 makes updates precise for whichever entity you plug in.
interface Entity { id: string }
class Repository<T extends Entity> {
private rows = new Map<string, T>();
add(row: T): void { this.rows.set(row.id, row); }
get(id: string): T | undefined { return this.rows.get(id); }
patch(id: string, changes: Partial<T>): T | undefined {
const row = this.rows.get(id);
if (!row) return undefined;
const next = { ...row, ...changes };
this.rows.set(id, next as T);
return next as T;
}
}
interface Student extends Entity { name: string; score: number }
const students = new Repository<Student>();
students.add({ id: 's1', name: 'Ada', score: 91 });
students.patch('s1', { score: 95 });
console.log(students.get('s1')?.name, students.get('s1')?.score);
students.patch('s1', { scrore: 95 }); // compile error
Expected output: Ada 95, then error TS2561: Object literal may only specify known properties, but 'scrore' does not exist in type 'Partial<Student>'. Did you mean to write 'score'? The any alternative is a store whose get() returns any, where students.get('s1').namee compiles and every entity property becomes a free-text field. The constraint does honest work too — new Repository<{ name: string }>() is rejected because id is missing.
Generic component props
The same pattern shapes UI code: a list component that renders any item type while keeping the callback typed. Inference fills in T from the items prop, so callers never state it explicitly.
import type { ReactNode } from 'react';
interface ListProps<T extends { id: string }> {
items: T[];
empty?: string;
renderItem: (item: T, index: number) => ReactNode;
}
function List<T extends { id: string }>({ items, empty = 'Nothing here yet', renderItem }: ListProps<T>) {
if (items.length === 0) return <p className="empty">{empty}</p>;
return (
<ul>
{items.map((item, i) => (
<li key={item.id}>{renderItem(item, i)}</li>
))}
</ul>
);
}
const lessonList = <List items={lessons} renderItem={(lesson, i) => <strong>{`${i + 1}. ${lesson.title}`}</strong>} />;
const broken = <List items={lessons} renderItem={(lesson) => <span>{lesson.mins}</span>} />;
Expected output: the first list renders 1. Generics, and the second fails with error TS2339: Property 'mins' does not exist on type 'Lesson'. The constraint extends { id: string } is what allows key={item.id}, and inside renderItem the parameter is a real Lesson — if it were any, lesson.mins would compile silently. If the shape of prop-driven components is new to you, the React props chapter covers the runtime side; chapter 19 adds TypeScript to every component you have written.
Common mistakes
- Using
Twithout a constraint, then reaching forT.id— the compiler answersTS2339: Property 'id' does not exist on type 'T'; declareT extends { id: string }instead. - Reaching for
anyto silence a generic — casting toanyconverts a compile error into a runtime error; if a constraint is genuinely loose, model it withunknownand narrow. - Generic parameters nobody uses — a
<T>on a function with one call site and one concrete type adds noise; write the plain type first. - Forgetting that
.get()returnsT | undefined— non-null assertions (!) hide missing rows; check or throw instead. - Letting inference pick the wrong candidate — if two props could supply
T, add the explicit argument (<List<Lesson>>) so the editor stops guessing.
Practice checklist
- Convert your own untyped
fetchcall intofetchJson<T>and read the error you get from a misspelled field. - Extend the emitter with a third event and confirm a wrong payload is rejected at the
emitcall. - Write
groupByfrom memory and use it twice — once grouping by a string field, once by a number field. - Add a
remove(id)method toRepositoryand acount()that returnsrows.size. - Render the generic
Listwith two different item types and confirm eachrenderItemcallback is typed independently.
What's next?
Generic code usually lives in its own files — and that raises a new question: how do types cross module boundaries? Find out in Modules, Declaration Files and d.ts.
Frequently Asked Questions
TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.
You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.
No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.
If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.
Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.
strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.
any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.
Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.
Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.
Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.