We use cookies to enhance your experience on the site
CodeWorlds

Generic Classes

"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()}`); // 2

In 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:

  1. Data collections and containers - when you need to store and manage elements of different types
  2. Services and repositories - when implementing design patterns that should work with different data types
  3. Reusable components - when you want to create flexible components that can be used in different contexts
  4. 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.

Go to CodeWorlds