JavaScript and TypeScript course Β· Module 7: Advanced TypeScript
Generic Classes
In this lesson9
"Every enclosure in Jurassic Park must be adapted to a specific dinosaur species," explains Dr. Ellie Sattler, head of the botanical research department. "But despite the differences, all enclosures operate according to the same principles - entrance control, environmental condition monitoring, automated feeding systems. What distinguishes a velociraptor enclosure from a triceratops enclosure is not its structure, but its parameters."
Generic classes in TypeScript work the same way. They allow creating reusable components that can work with different data types while maintaining full type control. If generic functions showed us the basic idea of generics, generic classes demonstrate the full potential of this mechanism.
What Are Generic Classes?
Generic classes are class templates that can work with different data types. Just like generic functions, they use type parameters (e.g., <T>), but they allow using those parameters throughout the entire class - in fields, methods, and constructors.
Here's a simple generic class representing a dinosaur enclosure:
1class DinosaurEnclosure<T> {
2 private inhabitants: T[];
3
4 constructor() {
5 this.inhabitants = [];
6 }
7
8 addInhabitant(dino: T): void {
9 this.inhabitants.push(dino);
10 console.log("Added new inhabitant to enclosure!");
11 }
12
13 getInhabitants(): T[] {
14 return [...this.inhabitants]; // We return a copy to avoid direct modification
15 }
16
17 countInhabitants(): number {
18 return this.inhabitants.length;
19 }
20}The above class can be used to create an enclosure for any type of dinosaurs:
1// Dinosaur type definitions
2interface Velociraptor {
3 name: string;
4 speed: number;
5 clawLength: number;
6}
7
8interface Triceratops {
9 name: string;
10 hornLength: number;
11 weight: number;
12}
13
14// Creating specialized enclosures
15const raptorEnclosure = new DinosaurEnclosure<Velociraptor>();
16const triceratopsEnclosure = new DinosaurEnclosure<Triceratops>();
17
18// Adding inhabitants to enclosures
19raptorEnclosure.addInhabitant({
20 name: "Blue",
21 speed: 70,
22 clawLength: 15
23});
24
25triceratopsEnclosure.addInhabitant({
26 name: "Topsy",
27 hornLength: 80,
28 weight: 9000
29});
30
31// Checking inhabitants
32console.log(`Velociraptor count: ${raptorEnclosure.countInhabitants()}`);
33console.log(`Triceratops count: ${triceratopsEnclosure.countInhabitants()}`);
34
35// TypeScript ensures type safety
36// This won't compile:
37// raptorEnclosure.addInhabitant({
38// name: "Invalid",
39// hornLength: 50,
40// weight: 7000
41// });Classes with Multiple Type Parameters
Generic classes can use multiple type parameters, allowing even greater flexibility:
1class ParkResearchLab<TSpecimen, TResult> {
2 private specimens: TSpecimen[] = [];
3 private results: Map<string, TResult> = new Map();
4
5 addSpecimen(id: string, specimen: TSpecimen): void {
6 this.specimens.push(specimen);
7 console.log(`Added sample #${id}`);
8 }
9
10 recordResult(specimenId: string, result: TResult): void {
11 this.results.set(specimenId, result);
12 console.log(`Saved result for sample #${specimenId}`);
13 }
14
15 getResult(specimenId: string): TResult | undefined {
16 return this.results.get(specimenId);
17 }
18}
19
20// Example usage with specific types
21interface BloodSample {
22 dinosaurId: string;
23 extractionDate: Date;
24 volume: number;
25}
26
27interface BloodTestResult {
28 oxygenLevel: number;
29 whiteBloodCellCount: number;
30 isHealthy: boolean;
31}
32
33const bloodLab = new ParkResearchLab<BloodSample, BloodTestResult>();
34
35bloodLab.addSpecimen("BLD-001", {
36 dinosaurId: "TREX-01",
37 extractionDate: new Date(2023, 4, 15),
38 volume: 250
39});
40
41bloodLab.recordResult("BLD-001", {
42 oxygenLevel: 95,
43 whiteBloodCellCount: 7500,
44 isHealthy: true
45});
46
47const result = bloodLab.getResult("BLD-001");
48console.log(`Wynik badania krwi: ${result?.isHealthy ? "Zdrowy" : "Wymaga uwagi"}`);Extending Generic Classes
Generic classes can also be extended, allowing for more specialized implementations:
1class MonitoredEnclosure<T> extends DinosaurEnclosure<T> {
2 private lastCheckTime: Date | null = null;
3 private securityLevel: "low" | "medium" | "high";
4
5 constructor(securityLevel: "low" | "medium" | "high" = "medium") {
6 super(); // Calling the base class constructor
7 this.securityLevel = securityLevel;
8 }
9
10 performSecurityCheck(): void {
11 this.lastCheckTime = new Date();
12 console.log(`Security check performed at ${this.lastCheckTime.toLocaleTimeString()}`);
13 console.log(`Security level: ${this.securityLevel}`);
14 console.log(`Inhabitant count: ${this.countInhabitants()}`);
15 }
16
17 getLastCheckTime(): Date | null {
18 return this.lastCheckTime;
19 }
20
21 upgradeSecurityLevel(): void {
22 if (this.securityLevel === "low") {
23 this.securityLevel = "medium";
24 } else if (this.securityLevel === "medium") {
25 this.securityLevel = "high";
26 }
27 console.log(`Updated security level to: ${this.securityLevel}`);
28 }
29}
30
31// Using the extended class
32const monitoredRaptorEnclosure = new MonitoredEnclosure<Velociraptor>("high");
33
34monitoredRaptorEnclosure.addInhabitant({
35 name: "Delta",
36 speed: 65,
37 clawLength: 14
38});
39
40monitoredRaptorEnclosure.performSecurityCheck();Default Type Parameters
Just as functions can have default parameters, generic classes can have default types for their type parameters:
1// Default dinosaur interface
2interface DefaultDinosaur {
3 name: string;
4 species: string;
5 age: number;
6}
7
8// Class with a default type parameter
9class DinosaurTracker<T = DefaultDinosaur> {
10 private trackedDinos: T[] = [];
11
12 trackDinosaur(dino: T, location: string): void {
13 this.trackedDinos.push(dino);
14 console.log(`Started tracking dinosaur at location: ${location}`);
15 }
16
17 getTrackedCount(): number {
18 return this.trackedDinos.length;
19 }
20}
21
22// Usage with default type
23const defaultTracker = new DinosaurTracker();
24defaultTracker.trackDinosaur({
25 name: "Rexy",
26 species: "Tyrannosaurus Rex",
27 age: 7
28}, "Sektor B");
29
30// Usage with a custom type
31interface AquaticDinosaur {
32 name: string;
33 underwaterSpeedKmh: number;
34 maxDiveDepthMeters: number;
35}
36
37const aquaticTracker = new DinosaurTracker<AquaticDinosaur>();
38aquaticTracker.trackDinosaur({
39 name: "Nessie",
40 underwaterSpeedKmh: 40,
41 maxDiveDepthMeters: 500
42}, "Jezioro Centralne");Static Properties in Generic Classes
It's important to understand that type parameters in generic classes only apply to instance properties and methods. Static properties and methods are shared between all instances of the class, regardless of their type parameters:
1class DinosaurRegistry<T> {
2 private static totalRegisteredDinos: number = 0;
3 private registeredDinos: T[] = [];
4
5 registerDinosaur(dino: T): void {
6 this.registeredDinos.push(dino);
7 DinosaurRegistry.totalRegisteredDinos++;
8 console.log(`Registered new dinosaur. Total: ${DinosaurRegistry.totalRegisteredDinos}`);
9 }
10
11 static getTotalRegisteredCount(): number {
12 return DinosaurRegistry.totalRegisteredDinos;
13 }
14}
15
16const rexRegistry = new DinosaurRegistry<Velociraptor>();
17const triRegistry = new DinosaurRegistry<Triceratops>();
18
19rexRegistry.registerDinosaur({
20 name: "Charlie",
21 speed: 68,
22 clawLength: 12
23});
24
25triRegistry.registerDinosaur({
26 name: "Cera",
27 hornLength: 75,
28 weight: 8500
29});
30
31console.log(`Total registered dinosaurs: ${DinosaurRegistry.getTotalRegisteredCount()}`); // 2In this example, the static variable totalRegisteredDinos is shared across all instances of DinosaurRegistry, regardless of their type parameters.
Generic Classes and Generic Interfaces
Generic classes often work with generic interfaces, creating a comprehensive type system:
1// Generic interface
2interface Repository<T> {
3 add(item: T): void;
4 getById(id: string): T | undefined;
5 getAll(): T[];
6 update(id: string, item: T): boolean;
7 delete(id: string): boolean;
8}
9
10// Implementation of a generic class conforming to a generic interface
11class DinosaurRepository<T extends { id: string }> implements Repository<T> {
12 private items: Map<string, T> = new Map();
13
14 add(item: T): void {
15 this.items.set(item.id, item);
16 }
17
18 getById(id: string): T | undefined {
19 return this.items.get(id);
20 }
21
22 getAll(): T[] {
23 return Array.from(this.items.values());
24 }
25
26 update(id: string, item: T): boolean {
27 if (this.items.has(id)) {
28 this.items.set(id, item);
29 return true;
30 }
31 return false;
32 }
33
34 delete(id: string): boolean {
35 return this.items.delete(id);
36 }
37}
38
39// Using the repository with a specific type
40interface DinosaurRecord {
41 id: string;
42 name: string;
43 species: string;
44 dateOfBirth: Date;
45}
46
47const dinoRepo = new DinosaurRepository<DinosaurRecord>();
48
49dinoRepo.add({
50 id: "DINO-001",
51 name: "Rex",
52 species: "Tyrannosaurus",
53 dateOfBirth: new Date(2018, 3, 10)
54});
55
56const rex = dinoRepo.getById("DINO-001");
57console.log(`Znaleziony dinosaur: ${rex?.name}, Species: ${rex?.species}`);Practical Example: Jurassic Park Asset Management System
Let's now expand our knowledge of generic classes by implementing a comprehensive asset management system for Jurassic Park:
1// Basic interface for all park assets
2interface ParkAsset {
3 id: string;
4 name: string;
5 status: "operational" | "maintenance" | "offline";
6 lastUpdated: Date;
7}
8
9// Different asset types
10interface Vehicle extends ParkAsset {
11 type: "jeep" | "boat" | "helicopter";
12 passengerCapacity: number;
13 currentFuel: number;
14}
15
16interface Facility extends ParkAsset {
17 type: "visitor" | "research" | "security";
18 location: { x: number, y: number };
19 capacity: number;
20}
21
22interface PowerSystem extends ParkAsset {
23 type: "main" | "backup" | "sector";
24 outputCapacity: number;
25 currentLoad: number;
26}
27
28// Generic interface for asset management
29interface AssetManager<T extends ParkAsset> {
30 registerAsset(asset: T): void;
31 getAsset(id: string): T | undefined;
32 updateStatus(id: string, status: T["status"]): boolean;
33 getOperationalAssets(): T[];
34}
35
36// Implementation of a generic asset management class
37class JurassicParkAssetManager<T extends ParkAsset> implements AssetManager<T> {
38 private assets: Map<string, T> = new Map();
39 private statusChangeLog: { assetId: string, oldStatus: string, newStatus: string, timestamp: Date }[] = [];
40
41 constructor(private assetType: string) {
42 console.log(`Initialized asset management system for type: ${assetType}`);
43 }
44
45 registerAsset(asset: T): void {
46 if (this.assets.has(asset.id)) {
47 throw new Error(`Asset o ID ${asset.id} already exists`);
48 }
49
50 this.assets.set(asset.id, {
51 ...asset,
52 lastUpdated: new Date()
53 });
54
55 console.log(`Zarejestrowano nowy asset: ${asset.name} (ID: ${asset.id})`);
56 }
57
58 getAsset(id: string): T | undefined {
59 return this.assets.get(id);
60 }
61
62 updateStatus(id: string, status: T["status"]): boolean {
63 const asset = this.assets.get(id);
64
65 if (!asset) {
66 console.warn(`Nie znaleziono assetu o ID ${id}`);
67 return false;
68 }
69
70 const oldStatus = asset.status;
71
72 // Status update
73 const updatedAsset = {
74 ...asset,
75 status,
76 lastUpdated: new Date()
77 };
78
79 this.assets.set(id, updatedAsset);
80
81 // Logging the change
82 this.statusChangeLog.push({
83 assetId: id,
84 oldStatus,
85 newStatus: status,
86 timestamp: new Date()
87 });
88
89 console.log(`Zaktualizowano status assetu ${asset.name} z "${oldStatus}" na "${status}"`);
90 return true;
91 }
92
93 getOperationalAssets(): T[] {
94 return Array.from(this.assets.values())
95 .filter(asset => asset.status === "operational");
96 }
97
98 getStatusChangeHistory(id?: string): typeof this.statusChangeLog {
99 if (id) {
100 return this.statusChangeLog.filter(log => log.assetId === id);
101 }
102 return [...this.statusChangeLog];
103 }
104
105 generateReport(): string {
106 const totalAssets = this.assets.size;
107 const operational = this.getOperationalAssets().length;
108 const maintenance = Array.from(this.assets.values())
109 .filter(asset => asset.status === "maintenance").length;
110 const offline = Array.from(this.assets.values())
111 .filter(asset => asset.status === "offline").length;
112
113 return `
114ASSET STATUS REPORT: ${this.assetType.toUpperCase()}
115======================================
116Total count: ${totalAssets}
117Operacyjne: ${operational} (${Math.round(operational / totalAssets * 100)}%)
118W konserwacji: ${maintenance} (${Math.round(maintenance / totalAssets * 100)}%)
119Offline: ${offline} (${Math.round(offline / totalAssets * 100)}%)
120======================================
121Wygenerowano: ${new Date().toLocaleString()}
122 `;
123 }
124}
125
126// Using the asset management system with different types
127const vehicleManager = new JurassicParkAssetManager<Vehicle>("Pojazdy");
128const facilityManager = new JurassicParkAssetManager<Facility>("Obiekty");
129const powerManager = new JurassicParkAssetManager<PowerSystem>("Zasilanie");
130
131// Registering sample assets
132vehicleManager.registerAsset({
133 id: "VEH-001",
134 name: "Explorer 04",
135 status: "operational",
136 lastUpdated: new Date(),
137 type: "jeep",
138 passengerCapacity: 5,
139 currentFuel: 75
140});
141
142vehicleManager.registerAsset({
143 id: "VEH-002",
144 name: "Explorer 05",
145 status: "maintenance",
146 lastUpdated: new Date(),
147 type: "jeep",
148 passengerCapacity: 5,
149 currentFuel: 30
150});
151
152facilityManager.registerAsset({
153 id: "FAC-001",
154 name: "Visitor Center",
155 status: "operational",
156 lastUpdated: new Date(),
157 type: "visitor",
158 location: { x: 100, y: 200 },
159 capacity: 200
160});
161
162powerManager.registerAsset({
163 id: "PWR-001",
164 name: "Main Power Grid",
165 status: "operational",
166 lastUpdated: new Date(),
167 type: "main",
168 outputCapacity: 10000,
169 currentLoad: 6500
170});
171
172// Simulating failures and status updates
173vehicleManager.updateStatus("VEH-001", "maintenance");
174powerManager.updateStatus("PWR-001", "offline");
175facilityManager.updateStatus("FAC-001", "maintenance");
176
177// After the fix
178setTimeout(() => {
179 vehicleManager.updateStatus("VEH-001", "operational");
180 powerManager.updateStatus("PWR-001", "operational");
181 facilityManager.updateStatus("FAC-001", "operational");
182
183 // Generating reports
184 console.log(vehicleManager.generateReport());
185 console.log(powerManager.generateReport());
186
187 // Status change history
188 console.log("Main power status change history:");
189 console.log(powerManager.getStatusChangeHistory("PWR-001"));
190}, 3000);When to Use Generic Classes?
Generic classes are especially useful in the following situations:
- Data collections and containers - when you need to store and manage elements of different types
- Services and repositories - when implementing design patterns that should work with different data types
- Reusable components - when you want to create flexible components that can be used in different contexts
- Abstractions at different levels - when you want to define class hierarchies that can be specialized for different types
Summary
Generic classes are a powerful tool in TypeScript that allows creating flexible, type-safe code. They provide an excellent balance between reusability and type safety, enabling the creation of components that:
- Can work with different data types
- Maintain full type information
- Provide compiler and IDE support (suggestions, type verification)
- Are readable and understandable for other programmers
Just like in Jurassic Park, where infrastructure must be flexible to accommodate different dinosaur species, generic classes allow us to build systems that are both flexible and safe - the perfect combination for modern software development.
In the next exercise, we'll look at how we can further refine our generics using generic constraints to ensure that our generic classes and functions only work with types that meet specific requirements.
Code for this lesson: index.ts
1// Generic classes - Jurassic Park
2console.log("=== GENERIC CLASSES ===\n");
3
4// ===========================================
5// 1. GenericRepository<T> - a data repository
6// ===========================================
7
8interface Identifiable {
9 id: number;
10 name: string;
11}
12
13class GenericRepository<T extends Identifiable> {
14 private items: Map<number, T> = new Map();
15 private label: string;
16
17 constructor(label: string) {
18 this.label = label;
19 }
20
21 add(item: T): void {
22 this.items.set(item.id, item);
23 }
24
25 getById(id: number): T | undefined {
26 return this.items.get(id);
27 }
28
29 getAll(): T[] {
30 return Array.from(this.items.values());
31 }
32
33 remove(id: number): boolean {
34 return this.items.delete(id);
35 }
36
37 count(): number {
38 return this.items.size;
39 }
40
41 find(predicate: (item: T) => boolean): T[] {
42 return this.getAll().filter(predicate);
43 }
44
45 toString(): string {
46 return `[${this.label}] ${this.count()} records`;
47 }
48}
49
50// ===========================================
51// 2. Collection<T> - a collection with iteration
52// ===========================================
53
54class Collection<T> {
55 private elements: T[] = [];
56
57 add(...items: T[]): Collection<T> {
58 this.elements.push(...items);
59 return this; // Fluent API
60 }
61
62 first(): T | undefined {
63 return this.elements[0];
64 }
65
66 last(): T | undefined {
67 return this.elements[this.elements.length - 1];
68 }
69
70 map<U>(fn: (item: T) => U): Collection<U> {
71 const result = new Collection<U>();
72 result.add(...this.elements.map(fn));
73 return result;
74 }
75
76 filter(fn: (item: T) => boolean): Collection<T> {
77 const result = new Collection<T>();
78 result.add(...this.elements.filter(fn));
79 return result;
80 }
81
82 toArray(): T[] {
83 return [...this.elements];
84 }
85
86 get size(): number {
87 return this.elements.length;
88 }
89}
90
91// ===========================================
92// 3. Pair<A, B> - a pair of values
93// ===========================================
94
95class Pair<A, B> {
96 constructor(
97 public readonly first: A,
98 public readonly second: B
99 ) {}
100
101 swap(): Pair<B, A> {
102 return new Pair(this.second, this.first);
103 }
104
105 toString(): string {
106 return `(${this.first}, ${this.second})`;
107 }
108}
109
110// ===========================================
111// Test
112// ===========================================
113
114// --- GenericRepository ---
115interface Dinosaur {
116 id: number;
117 name: string;
118 species: string;
119 weight: number;
120}
121
122interface Enclosure {
123 id: number;
124 name: string;
125 capacity: number;
126}
127
128const dinoRepo = new GenericRepository<Dinosaur>("Dinosaurs");
129dinoRepo.add({ id: 1, name: "Rexy", species: "T-Rex", weight: 8000 });
130dinoRepo.add({ id: 2, name: "Blue", species: "Velociraptor", weight: 15 });
131dinoRepo.add({ id: 3, name: "Cera", species: "Triceratops", weight: 6000 });
132
133const encRepo = new GenericRepository<Enclosure>("Enclosures");
134encRepo.add({ id: 101, name: "T-Rex Zone", capacity: 2 });
135encRepo.add({ id: 102, name: "Raptor Valley", capacity: 6 });
136
137console.log(dinoRepo.toString());
138console.log(encRepo.toString());
139
140console.log("\nDinosaur #2:", dinoRepo.getById(2)?.name);
141console.log("Heavy (>1000kg):", dinoRepo.find(d => d.weight > 1000).map(d => d.name).join(", "));
142
143// --- Collection ---
144console.log("\n--- Collection with a fluent API ---");
145const names = new Collection<string>()
146 .add("Rexy", "Blue", "Delta", "Charlie", "Cera")
147 .filter(n => n.length <= 4)
148 .map(n => n.toUpperCase());
149
150console.log("Short names:", names.toArray().join(", "));
151console.log("First:", names.first());
152console.log("Last:", names.last());
153
154// --- Pair ---
155console.log("\n--- Pair ---");
156const dinoEnclosure = new Pair<string, string>("Rexy", "T-Rex Zone");
157console.log("Pair:", dinoEnclosure.toString());
158
159const swapped = dinoEnclosure.swap();
160console.log("Swapped:", swapped.toString());
161
162const idWeight = new Pair<number, number>(1, 8000);
163console.log("ID-Weight:", idWeight.toString());
164
165console.log("\nGeneric classes guarantee type safety!");Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. When is it better to use a generic <T> instead of a union type (string | number)?
Hands-on tasks in the game
- Code editor
Create a generic data repository for the park
- Vertical ordering
Order the access modifiers from most restrictive to broadest: