Generic classes
Write TypeScript classes like Stack<T> and Cache<T> that keep their item type, see why static members can't use T, and add generic methods.
- 01Write a generic classGive a class a type parameter, like
class Stack<T>, and useTin its fields, constructor, and methods. - 02Fill in a class's TFill in
Twithnew Stack<string>()or from the constructor's arguments, and spot when it becomesunknown. - 03Use static members and generic methodsExplain why static members can't use
T, and give a method its own type parameter, likemap<U>.
A stack that keeps its item type
A text editor's undo button needs to remember your changes, newest first. A stack does that: a list where you add and remove items at the same end, like a pile of plates. push puts an item on top, and pop takes the top one off. Here is a stack class written for text (the Classes in TypeScript lesson covers fields and methods):
class TextStack { private items: string[] = []; push(item: string) { this.items.push(item); } pop(): string | undefined { return this.items.pop(); }} const edits = new TextStack();edits.push("Hello");const zoom = new TextStack();zoom.push(150);console.log(edits.pop(), zoom.pop());Line 2 keeps the items in a private string[]. Line 3's push takes a string, and line 6's pop gives back the top string, or undefined when the stack is empty. Lines 11 and 12 make a stack of edits and push "Hello". Line 13 makes a second stack for zoom levels, and line 14 pushes 150. TypeScript marks it, on purpose:
Argument of type 'number' is not assignable to parameter of type 'string'means "you passed a number where this method wants text".
Run it anyway and line 15 prints Hello 150: the code works for any item, and only the types say text. You could copy the class as NumberStack, and again for every kind of item. A generic class writes it once:
class Stack<T> { private items: T[] = []; push(item: T) { this.items.push(item); } pop(): T | undefined { return this.items.pop(); }} const edits = new Stack<string>();edits.push("Hello");const zoom = new Stack<number>();zoom.push(150);const lastZoom = zoom.pop();// ^?console.log(edits.pop(), lastZoom);Line 1 has the new part: <T> after the class name. As in the Generic types lesson, T is a type parameter, a blank for a type. Line 2 keeps a T[], line 3 takes a T, and line 6 gives one back.
Line 11 writes new Stack<string>(): the type argument string fills in T for this stack. Line 13 makes a Stack<number>, so pushing 150 on line 14 is fine. The query on line 16 shows const lastZoom: number | undefined. There are no errors, and line 17 prints Hello 150.
edits with T filled in as string, and line 13 makes zoom with T as number. Each stack only takes, and gives back, its own kind of item.A shop orders one shelf design for its storeroom. Each time a shelf goes up, it gets a label: "cups", "plates", "bowls". Staff only put cups on the cups shelf, so anyone who takes something off it knows it's a cup without looking.
- In real life: One shelf design in the catalogue
- In TypeScript: One class:
class Stack<T> - In real life: Putting up a shelf and labelling it "cups"
- In TypeScript: Making a stack:
new Stack<string>() - In real life: Staff can only put cups on the cups shelf
- In TypeScript:
edits.push(150)is an error - In real life: Whatever you take off the cups shelf is a cup
- In TypeScript:
edits.pop()is astring, orundefined
A generic class has a type parameter after its name, like class Stack<T>. Its fields, constructor, and methods use T. Each object you make fills in T, like new Stack<string>(), and keeps that type for as long as it exists.
Type parameters on a class
You can write the type argument after new, as in the overview. When the constructor takes a T, TypeScript can also fill in T from the value you pass. A drop-down list on a shop page is a good example: each choice has a label to show and a value to use.
class Choice<T> { constructor(public label: string, public value: T) {}} const size = new Choice("Large", "L");// ^?const quantity = new Choice("Two", 2);// ^?console.log(size.value, quantity.value + 1);Line 2 is a constructor with two parameter properties: public label: string and public value: T become fields straight from the constructor's arguments (see Classes in TypeScript). Line 5 passes "L" as the value, so TypeScript fills in T with string, and line 6 shows const size: Choice<string>. Line 7 passes 2, and line 8 shows Choice<number>. Line 9 prints L 3.
A type argument you write wins, and the constructor's arguments are checked against it:
class Choice<T> { constructor(public label: string, public value: T) {}} const size = new Choice<string>("Large", "L");const quantity = new Choice<number>("Two", "2");console.log(size.value, quantity.value);Line 5 writes <string>, the same type TypeScript would fill in. Line 6 writes <number> and passes the text "2", so TypeScript marks it: Argument of type 'string' is not assignable to parameter of type 'number'. Line 7 prints L 2 anyway.
Our Stack's constructor takes nothing, so only a type argument can fill in its T. Here an undo history pushes "Hello" and shows the last change with showMessage, which needs text. Press the buttons to make the history three ways on line 15:
class Stack<T> { private items: T[] = []; push(item: T) { this.items.push(item); } pop(): T | undefined { return this.items.pop(); }} function showMessage(text: string | undefined) { console.log(text ?? "Nothing to undo");} const history = new Stack<string>();history.push("Hello");const last = history.pop();// ^?showMessage(last);string | undefinedNo errorsprints Hellonew Stack<string>() fills in T with string. Pushing "Hello" fits, pop gives back string | undefined, and showMessage accepts it. No errors.
With <string>, everything fits. With No type argument, nothing tells TypeScript what T is, so it becomes unknown, a type that means "this could be anything". The stack takes any push, and what comes out can't be used as text. TypeScript doesn't look at the later push("Hello") to decide. With <number>, line 16's text doesn't fit.
A typed Stack and Cache
A cache keeps server replies so the page doesn't ask for them twice. Each reply is stored under a key, like the address it came from. A generic class can use other generic types inside it, like a Map:
type Product = { name: string; price: number }; class Cache<T> { private entries = new Map<string, T>(); set(key: string, value: T) { this.entries.set(key, value); } get(key: string): T | undefined { return this.entries.get(key); }} const productCache = new Cache<Product[]>();productCache.set("/products", [{ name: "Tea", price: 3 }]);const cached = productCache.get("/products");// ^?console.log(cached?.length, productCache.get("/sale"));Line 3 declares Cache<T>. Line 4 keeps the replies in a Map<string, T>: text keys, and values of whatever type T is (the Map and Set lesson covers Map). Line 5's set takes a T, and line 8's get gives back a T, or undefined for a key it doesn't have.
Line 13 makes a cache for lists of products, Cache<Product[]>. Line 14 stores one list, and the query on line 16 shows const cached: Product[] | undefined. Line 17 prints 1 undefined: one product, and nothing stored under "/sale". The ?. reads length only when cached isn't undefined.
Now change what the cache stores. Type a type argument for line 13, and watch line 14 and the answer on line 16:
type Product = { name: string; price: number }; class Cache<T> { private entries = new Map<string, T>(); set(key: string, value: T) { this.entries.set(key, value); } get(key: string): T | undefined { return this.entries.get(key); }} const productCache = new Cache<Product[]>();productCache.set("/products", [{ name: "Tea", price: 3 }]);const cached = productCache.get("/products");// ^?console.log(cached?.length, productCache.get("/sale"));Product[]Product[] | undefinedNo errorsCache<Product[]> stores lists of products. Line 14 stores one, so there are no errors, and cached is Product[] | undefined.
Product without the brackets is one product, so a list doesn't fit. string doesn't fit either. Array<Product> is the same as Product[]. any accepts the list, but then TypeScript stops checking what comes out.
A generic class can also promise to match a generic interface. Write the interface's type argument after implements (the implements and abstract classes lesson covers implements):
interface Store<T> { get(key: string): T | undefined; set(key: string, value: T): void;} class MemoryStore<T> implements Store<T> { private entries = new Map<string, T>(); get(key: string) { return this.entries.get(key); } set(key: string, value: T) { this.entries.set(key, value); }} const preferences: Store<string> = new MemoryStore<string>();preferences.set("theme", "dark");console.log(preferences.get("theme"));Lines 1 to 4 describe a Store<T>: anything with a get and a set for values of type T. Line 6 says MemoryStore<T> implements Store<T>, passing its own T on, so a MemoryStore<string> is a Store<string>. Line 16 makes a store for a page's preferences as text, line 17 stores the theme, and line 18 prints dark. Code that takes a Store<string> works with this class, or with any other class that stores text the same way.
Static members can't use T
A static member belongs to the class itself, not to each object (the Static members and class types lesson covers them). There is only one Stack class, however many kinds of stack you make:
class Stack<T> { static created = 0; private items: T[] = []; constructor() { Stack.created += 1; }} new Stack<string>();new Stack<number>();console.log(Stack.created);Line 2 makes a static created counter, and line 5 adds 1 each time a stack is made. Line 9 makes a Stack<string> and line 10 a Stack<number>. Line 11 prints 2: both stacks counted on the same static field. Stack<string> and Stack<number> are two types, but one class.
Stack class, and static members live on that one class. It has no T of its own, so a static member can't use T. That's why created counts both stacks, and why from brings its own type parameter, Item.So a static member can't use T: it belongs to the class, and the class serves every T at once. A common case is a static factory, a method on the class that makes objects for you, like Stack.from(["S", "M"]). Press the buttons to write line 9 three ways:
class Stack<T> { constructor(private items: T[] = []) {} push(item: T) { this.items.push(item); } pop(): T | undefined { return this.items.pop(); } static from(items: T[]): Stack<T> { return new Stack(items); }} const sizes = Stack.from(["S", "M"]);// ^?console.log(sizes.pop());Stack<T>2 errors: line 9prints Mfrom is static, so it belongs to the class itself, and the class has no T. TypeScript marks both uses of T on line 9: Static members cannot reference class type parameters. Line 15 shows Stack<T>: a blank nothing fills in.
static, using T gets Static members cannot reference class type parameters twice, once for each T. static from<Item> fixes it: the method gets its own type parameter, filled in for each call. Not static makes from a method of each stack, so the class has no from at all.
The table below shows where a class's T can go. Read each row as: this part of the class, can it use T, and why.
| Where in the class | Can it use the class's T? | Why |
|---|---|---|
An instance field, like items: T[] | Yes. | Each stack has its own items, made with its own T. |
A constructor parameter, like value: T | Yes. | new fills in T, from a type argument or from the arguments you pass. |
An instance method, like push(item: T) | Yes. | You call it on one stack, and that stack has a T. |
A static field or method, like static from | No. | It belongs to the class itself, shared by every T. Give a static method its own type parameter instead. |
Generic methods: a type parameter for one method
The class's T is filled in once, when the object is made. Sometimes a method needs a type that changes with each call. Then the method gets a type parameter of its own, written after its name, like the Generic functions lesson's functions:
class Stack<T> { constructor(private items: T[] = []) {} peek(): T | undefined { return this.items.at(-1); } map<U>(change: (item: T) => U): Stack<U> { return new Stack(this.items.map(change)); }} const prices = new Stack([3, 4]);// ^?const labels = prices.map((price) => "$" + price);// ^?console.log(labels.peek());Line 2's constructor takes the starting items. Line 3's peek gives back the top item without removing it: at(-1) reads the last one. Line 6 declares map<U>. It takes a change function that turns a T into a U, and gives back a Stack<U>. Line 7 uses the array's own map (see map, filter, and reduce).
Line 11 passes [3, 4], so the query on line 12 shows const prices: Stack<number>. Line 13 calls map with a callback that gives back text, so this call's U is string, and line 14 shows const labels: Stack<string>. Line 15 prints $4, the top label.
prices got T = number when line 11 made it. Line 13's call to map fills in the method's own U from the callback: it gives back text, so U is string, and you get a Stack<string>.A method uses the class's T without declaring it. Writing <T> on the method again makes a new T, unrelated to the class's:
class Stack<T> { private items: T[] = []; push<T>(item: T) { this.items.push(item); }} const tags = new Stack<string>();tags.push(3);Line 3 writes push<T>, so inside push, T means the method's own type parameter. Line 4 marks it: Argument of type 'T' is not assignable to parameter of type 'T'. In plain words: these are two different blanks that happen to share a name, and the method's T might not be the stack's. Worse, line 9 pushes 3 into a Stack<string> with no error, because push no longer uses the stack's T. Remove <T> from line 3, and both problems are gone.
The table below compares the two kinds of type parameter. Read each row as a question, then each answer.
| Question | The class's T | A method's own type parameter |
|---|---|---|
| When is it filled in? | Once, when the object is made: new Stack([3, 4]). | Each time you call the method: prices.map(…). |
| Where can you use it? | In every field and instance method of the class. | Only inside that one method. |
| What fills it in? | A type argument, or the constructor's arguments. | A type argument, or that call's arguments, like the callback. |
Undo for a settings panel
A settings panel lets the user change the theme and the font size, and undo their last change. The undo part doesn't care what the settings are, so one generic class can handle it, and the same class can undo text in a box:
type Settings = { theme: "light" | "dark"; fontSize: number }; class History<T> { private past: T[] = []; constructor(private current: T) {} get value(): T { return this.current; } change(next: T) { this.past.push(this.current); this.current = next; } undo() { const previous = this.past.pop(); if (previous !== undefined) this.current = previous; }} const settings = new History<Settings>({ theme: "light", fontSize: 16 });settings.change({ theme: "dark", fontSize: 16 });settings.change({ ...settings.value, fontSize: 18 });settings.undo();const now = settings.value;// ^?console.log(now.theme, now.fontSize); const draft = new History("Hi");// ^?draft.change("Hi Asha");draft.undo();console.log(draft.value);Line 1 describes the settings. Lines 3 to 17 are History<T>:
- Line 4 keeps the past values in a
T[], used as a stack. Line 5 takes the starting value as a privatecurrentfield. - Line 6 is a getter (see Getters and setters):
settings.valuereads the current value. - Lines 9 to 12 push the current value onto
past, then switch to the new one. - Lines 13 to 16 pop the last value off
pastand go back to it, if there is one.
Line 19 makes a History<Settings>. Line 20 switches to the dark theme. Line 21 copies the current settings with the spread ... (see Spread and rest) and changes the font size to 18. Line 22 undoes that, so the query on line 24 shows const now: Settings and line 25 prints dark 16.
Line 27 makes a history for a text box. The constructor's argument is text, so line 28 shows const draft: History<string>. Lines 29 and 30 change it and undo, and line 31 prints Hi.
For an object, the type argument matters. Without it, TypeScript fills in T from the starting value, and that can be too loose:
type Settings = { theme: "light" | "dark"; fontSize: number }; class History<T> { constructor(private current: T) {} change(next: T) { this.current = next; }} const settings = new History<Settings>({ theme: "light", fontSize: 16 });settings.change({ theme: "blue", fontSize: 16 });const guessed = new History({ theme: "light", fontSize: 16 });// ^?guessed.change({ theme: "blue", fontSize: 16 });Line 10 writes <Settings>, so line 11's typo is caught: Type '"blue"' is not assignable to type '"light" | "dark"' means "the theme must be one of these two words". Line 12 leaves the type argument out. TypeScript fills in T from the object, and the query on line 13 shows theme: string: any text. So line 14's "blue" gets no error. When the starting value doesn't say everything, write the type argument.
class Box<T> { constructor(public value: T) {} } new Box<number>("3");class Box<T> { constructor(public value: T) {} } new Box("3").value.toUpperCase();class Box<T> { static empty: T[] = []; }class Box<T> { static count = 0; value?: T; }class Box<T> { items: T[] = []; } new Box<string>().items.push(3);class Box<T> { items: T[] = []; } new Box().items.push(3);class Box<T> { constructor(public value: T) {} } const label: Box<string> = new Box(3);class Box<T> { value?: T; } const saved: unknown = new Box(); console.log(saved instanceof Box);
Sort each line by what TypeScript says about it. Each line is checked on its own.
Common mix-ups
Type parameters are removed before the code runs, like every type. So while the code runs, a stack doesn't know its T:
class Stack<T> { constructor(public items: T[] = []) {}} const saved: unknown = new Stack([3, 4]);if (saved instanceof Stack) { console.log(saved.items); // ^?}Line 5 stores a stack of numbers as unknown, as if it came from somewhere TypeScript can't see. Line 6 checks it with instanceof (the in and instanceof lesson covers it). Inside, TypeScript knows it's a Stack, but not which kind: the query on line 8 shows (property) Stack<any>.items: any[]. Line 7 prints [ 3, 4 ]. Writing saved instanceof Stack<number> is an error, because there's no T to check while the code runs.
The table below shows what is left of a generic class when the code runs. Read each row as: what you write, what TypeScript checks, and what runs.
| What you write | What TypeScript checks | What runs in the browser |
|---|---|---|
class Stack<T> { … } | Every use of T inside the class. | class Stack { … }: one class, with no T. |
new Stack<string>() | That everything pushed is text. | new Stack(). The type argument is removed. |
saved instanceof Stack | It narrows saved to Stack<any>. | It checks that Stack made the object. It can't see T. |
- "
Stack<string>andStack<number>are two classes." No. They're two types made from one class. A static field, likecreated, is shared by both. - "TypeScript works out
Tfrom what I push later." No.Tis filled in when the object is made.new Stack()with nothing to go on is aStack<unknown>. - "A static method can use the class's
T." No. Static members belong to the one class, which serves everyT. Give the static method its own type parameter, likefrom<Item>. - "A method must declare
<T>to useT." No. Every instance method can use the class'sT. Declaring it again makes a new, unrelatedT. - "
instanceofcan checkStack<string>." No. It only checks the class. The type argument is gone when the code runs. - "A class that holds any kind of item needs
anyinside." No.Tlets it hold any kind of item and still check each object's own kind.
Practice exercises
Each exercise is TypeScript. When you change code, press Edit & run on the starter code: the errors and ^? answers update as you type, and the console shows what it prints. Try a guess first, then use the hints.
What type does TypeScript give next? Guess first. Then point at next, or press Edit & run and add a // ^? line under it.
class Stack<T> { private items: T[] = []; push(item: T) { this.items.push(item); } pop(): T | undefined { return this.items.pop(); }} const orders = new Stack<string>();orders.push("Tea");const next = orders.pop();class Stack<T> { private items: T[] = []; push(item: T) { this.items.push(item); } pop(): T | undefined { return this.items.pop(); }} const orders = new Stack<string>();orders.push("Tea");const next = orders.pop();// ^?new Stack<string>() fills in T with string, and pop gives back T | undefined, so next is string | undefined. The query on line 14 shows it.
A stack of sizes, and one push with a mistake. Which line will TypeScript mark?
class Stack<T> {
private items: T[] = [];
push(item: T) {
this.items.push(item);
}
}
const sizes = new Stack<string>();
sizes.push("S");
sizes.push("M");
sizes.push(42);class Stack<T> { private items: T[] = []; push(item: T) { this.items.push(item); }} const sizes = new Stack<string>();sizes.push("S");sizes.push("M");sizes.push(42); // line 11: 42 is not textThe stack is a Stack<string>, so push takes text. Line 11 pushes the number 42: Argument of type 'number' is not assignable to parameter of type 'string'.
RecentList keeps the 3 most recent items, newest first, but only takes text, so TypeScript marks line 14. Make it generic. What does line 15 print then?
class RecentList { private items: string[] = []; add(item: string) { this.items = [item, ...this.items].slice(0, 3); } latest(): string | undefined { return this.items[0]; }} const viewed = new RecentList();viewed.add("Tea");const prices = new RecentList();prices.add(4);console.log(viewed.latest(), prices.latest());class RecentList<T> { private items: T[] = []; add(item: T) { this.items = [item, ...this.items].slice(0, 3); } latest(): T | undefined { return this.items[0]; }} const viewed = new RecentList<string>();viewed.add("Tea");const prices = new RecentList<number>();prices.add(4);console.log(viewed.latest(), prices.latest());With class RecentList<T>, each list keeps its own type: viewed holds text and prices holds numbers, so line 14 is fine. Line 15 prints Tea 4.
TypeScript marks line 17. Fix it with a type argument on line 15. What goes in the angle brackets?
class Cache<T> { private entries = new Map<string, T>(); set(key: string, value: T) { this.entries.set(key, value); } get(key: string): T | undefined { return this.entries.get(key); }} function showPrice(price: number | undefined) { console.log(price === undefined ? "No price" : "$" + price);} const prices = new Cache();prices.set("Tea", 3);showPrice(prices.get("Tea"));class Cache<T> { private entries = new Map<string, T>(); set(key: string, value: T) { this.entries.set(key, value); } get(key: string): T | undefined { return this.entries.get(key); }} function showPrice(price: number | undefined) { console.log(price === undefined ? "No price" : "$" + price);} const prices = new Cache<number>();prices.set("Tea", 3);showPrice(prices.get("Tea"));new Cache<number>() fills in T, so get gives back number | undefined, which showPrice accepts. The code prints $3.
Stack.of makes a stack from the items you pass, with a rest parameter ...items (see Spread and rest). TypeScript marks line 6. Fix it. What does the code print?
class Stack<T> { constructor(private items: T[] = []) {} pop(): T | undefined { return this.items.pop(); } static of(...items: T[]): Stack<T> { return new Stack(items); }} const sizes = Stack.of("S", "M", "L");console.log(sizes.pop());class Stack<T> { constructor(private items: T[] = []) {} pop(): T | undefined { return this.items.pop(); } static of<Item>(...items: Item[]): Stack<Item> { return new Stack(items); }} const sizes = Stack.of("S", "M", "L");// ^?console.log(sizes.pop());of<Item> has its own type parameter, filled in from the arguments, so sizes is a Stack<string>. pop takes the last size off, and the code prints L.
getOr gives back a stored value, or a fallback when there isn't one. TypeScript marks fallback on line 6 because it has no type. Give the method a type parameter. What type does size get?
class Cache<T> { private entries = new Map<string, T>(); set(key: string, value: T) { this.entries.set(key, value); } getOr(key: string, fallback) { const found = this.entries.get(key); return found === undefined ? fallback : found; }} const sizes = new Cache<string>();sizes.set("shirt", "M");const size = sizes.getOr("shoes", null);console.log(size);class Cache<T> { private entries = new Map<string, T>(); set(key: string, value: T) { this.entries.set(key, value); } getOr<F>(key: string, fallback: F): T | F { const found = this.entries.get(key); return found === undefined ? fallback : found; }} const sizes = new Cache<string>();sizes.set("shirt", "M");const size = sizes.getOr("shoes", null);// ^?console.log(size);F is filled in for each call: null here. The method gives back a stored T (text) or the fallback, so size is string | null. There are no shoes, so it prints null.
Check your understanding
For each question, ask when each type parameter is filled in: the class's T when the object is made, or a method's own one at each call. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7Where do you write a class's type parameter?
Choose an answer to see the explanation.
Question 2 of 7What type does
giftWrapget?Read the code, then predictclass Box<T> { constructor(public value: T) {} } const giftWrap = new Box(true);Choose an answer to see the explanation.
Question 3 of 7What type does
lastget from this stack made without a type argument?Read the code, then predictclass Stack<T> { private items: T[] = []; push(item: T) { this.items.push(item); } pop(): T | undefined { return this.items.pop(); } } const history = new Stack(); history.push("Hello"); const last = history.pop();Choose an answer to see the explanation.
Question 4 of 7Which line does TypeScript flag in this class with static members?
Read the code, then predictclass Stack<T> { private items: T[] = []; static created = 0; static empty(): T[] { return []; } }Choose an answer to see the explanation.
Question 5 of 7What type does
inStockget from this map call?Read the code, then predictclass Stack<T> { constructor(private items: T[] = []) {} map<U>(change: (item: T) => U): Stack<U> { return new Stack(this.items.map(change)); } } const inStock = new Stack([3, 0]).map((count) => count > 0);Choose an answer to see the explanation.
Question 6 of 7What does this count of created stacks print?
Read the code, then predictclass Stack<T> { static created = 0; constructor() { Stack.created += 1; } } new Stack<string>(); new Stack<number>(); new Stack<string>(); console.log(Stack.created);Choose an answer to see the explanation.
Question 7 of 7What can
saved instanceof Stacktell you while the code runs?Choose an answer to see the explanation.
Key takeaways
- A generic class has a type parameter after its name, like
class Stack<T>, used in its fields, constructor, and instance methods. - Each object fills in
Twhen it's made: from a type argument,new Stack<string>(), or from the constructor's arguments. - With nothing to fill it in,
Tisunknown. Later calls don't change it. - Static members belong to the one class, shared by every
T, so they can't useT. Give a static method its own type parameter. - A method can have its own type parameter, like
map<U>, filled in at each call. Don't declare the class'sTagain on a method. - While the code runs, there's no
T:instanceofchecks only the class.
Remember the one-liner.
A generic class is one class whose objects each fill in its blank, and keep that type for life.
Coming next: Generics in practice. It builds a typed fetch helper and a typed event emitter, and shows how to read the generic types in a library.