Chapter 11 of 24 · TypeScript for Beginners — Complete Free Tutorial
Generics
So far in the TypeScript for Beginners series every reusable function has had to choose: describe one precise type, or accept any and lose the checking you adopted TypeScript to get. Generics are the third option — functions, interfaces and classes that take a type as an argument and give back a type built from it. This chapter of the 24-chapter series builds that skill from the ground up.
Generic functions
A type parameter looks like a type name in angle brackets, conventionally T for “type”. It behaves exactly like a value parameter, except the caller passes a type instead of a value:
function identity<T>(value: T): T {
return value;
}
const word = identity<string>('hello');
const count = identity<number>(42);
console.log(word.toUpperCase(), count + 1);
Expected output:
HELLO 43
Inside the function, T is a real type: value is a T and the return value is a T. Compare that with the any version, function identityAny(value: any): any, which compiles identityAny(42).toUpperCase() happily and throws a TypeError in the browser. Generics keep the shape of the data and check it at the same time.
Letting TypeScript infer the type argument
You rarely write the angle brackets yourself. TypeScript infers T from the argument, the same way it infers an ordinary parameter's type:
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // number | undefined
console.log(n);
Expected output:
1
Pass an explicit type argument only when inference cannot decide — a function with no arguments, or a call where you deliberately want a wider type. Getting it wrong is still caught: first<string>([1, 2]) fails with Type 'number' is not assignable to type 'string' (TS2322).
Constraints with extends
A bare T carries no members at all, so value.length inside a generic function is an error. The extends clause imposes a lower bound — “T must be assignable to this” — and those members become available:
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
console.log(getLength('scriptly'), getLength([1, 2, 3, 4]), getLength({ length: 7 }));
Expected output:
8 4 7
The constraint is checked at every call: getLength(42) is rejected because a number has no length. Crucially, T itself is preserved — a function declared <T extends { length: number }>(value: T): T still returns the caller's exact type, which is what makes generic wrappers composable.
Generic interfaces, type aliases and classes
Type parameters work anywhere types are declared. The pattern you will meet most is a container that holds something whose type is supplied by the consumer:
interface ApiResponse<T> {
status: number;
data: T;
}
class Stack<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const chapters = new Stack<string>();
chapters.push('narrowing');
console.log(chapters.pop());
Expected output:
narrowing
ApiResponse<User> and ApiResponse<string[]> are two distinct types sharing one definition, and Stack<string> will not accept push(42). Each combination is checked as if you had written it by hand — without writing it by hand.
Two type parameters, and a keyof preview
Nothing stops you using several parameters; the convention is K for a key and V for a value, which is exactly how Map<K, V> and Record<K, V> are typed. A helper such as function setEntry<K, V>(key: K, value: V): [K, V] keeps both types independent and hands them back as a tuple:
function setEntry<K, V>(key: K, value: V): [K, V] {
return [key, value];
}
console.log(setEntry('chapters', 12));
Expected output:
[ 'chapters', 12 ]
The related operator you will want next is keyof: given an object type, it produces the union of that type's property names. For { title: string; slug: string; order: number }, keyof yields 'title' | 'slug' | 'order'. It combines with generics to make functions that accept only real property names — exactly what the worked example below does, and the subject of chapter 14.
The generics you already use
You have been using generics all along: array and promise types are generic types in disguise.
let names: string[] = ['Ada', 'Grace'];
let lookup: Array<string> = names; // the same type, two spellings
let future: Promise<number> = Promise.resolve(42);
future.then((value) => console.log(value * 2));
Expected output:
84
string[] is sugar for Array<string>, so names.push(true) is an error for the same reason Stack<string> rejects a number. Promise<number> is what lets await hand you a number instead of unknown; chapter 18 covers that in depth. The mental model is simple: a generic is a function from types to types, evaluated once per call or per declaration.
Worked example: pluck values by key
Here is the payoff — a tiny utility that is fully typed for any object, thanks to two type parameters and a keyof constraint:
interface Chapter {
title: string;
slug: string;
order: number;
}
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const chapters: Chapter[] = [
{ title: 'Introduction to TypeScript', slug: 'introduction-to-typescript', order: 1 },
{ title: 'Setting Up TypeScript', slug: 'setting-up-typescript', order: 2 },
];
console.log(pluck(chapters, 'slug'));
console.log(pluck(chapters, 'order'));
Expected output:
[
'introduction-to-typescript',
'setting-up-typescript'
]
[ 1, 2 ]
Read the signature slowly: T is the element type, K must be one of its keys, and the return type T[K][] is “an array of whatever lives under that key”. Call it wrong and nothing runs: pluck(chapters, 'titel') fails with Argument of type '"titel"' is not assignable to parameter of type 'keyof Chapter'. One utility, every object, no any in sight.
Common mistakes
- Reaching for
anyinstead of a type parameter —function wrap(value: any): anycompiles everywhere and checks nothing; add<T>. - Declaring
<T>that appears nowhere useful — ifTis not in the parameters, inference has nothing to work with and every caller must spell it out. - Forgetting the constraint —
Property 'length' does not exist on type 'T'means you needextends { length: number }, not a cast. - Writing explicit type arguments everywhere —
first<string>(['a'])is noise; let inference do its job. - Expecting generics at runtime — type parameters are erased when compiled, so
new T()andvalue instanceof Tare impossible by design.
Practice checklist
- Write
identityandfirstfrom memory and check the inferred types with your editor. - Build a generic
clamp<T extends number>(value: T, min: number, max: number): Tand call it with three numbers. - Create an
ApiResponse<T>interface and produce one response of a custom object type and one ofstring[]. - Extend
Stack<T>with apeek(): T | undefinedmethod and keep the element type intact. - Re-type one untyped utility from a JavaScript project as a generic and delete its
any.
What's next?
Generics describe data; classes bundle it with behaviour — next, TypeScript's take on Classes and Access Modifiers.
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.