Cours JavaScript et TypeScript · Module 7: TypeScript avancé

Classes génériques

10 min de lecture
Dans cette leçon9

« Chaque enclos de Jurassic Park doit être adapté à une espèce précise de dinosaure », explique le Dr Ellie Sattler, responsable du département de recherche botanique. « Mais malgré les différences, tous les enclos fonctionnent selon les mêmes principes - contrôle des entrées, surveillance des conditions environnementales, systèmes d'alimentation automatisés. Ce qui distingue un enclos de vélociraptors d'un enclos de tricératops, ce n'est pas sa structure, mais ses paramètres. »

Les classes génériques en TypeScript fonctionnent de la même manière. Elles permettent de créer des composants réutilisables capables de travailler avec différents types de données tout en conservant un contrôle complet des types. Si les fonctions génériques nous ont montré l'idée de base des génériques, les classes génériques en démontrent tout le potentiel.

Que sont les classes génériques ?

Les classes génériques sont des modèles de classes capables de travailler avec différents types de données. Tout comme les fonctions génériques, elles utilisent des paramètres de type (par ex. <T>), mais elles permettent d'utiliser ces paramètres dans toute la classe - dans les champs, les méthodes et les constructeurs.

Voici une classe générique simple représentant un enclos de dinosaures :

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}

La classe ci-dessus peut être utilisée pour créer un enclos pour n'importe quel type de dinosaures :

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 à plusieurs paramètres de type

Les classes génériques peuvent utiliser plusieurs paramètres de type, ce qui offre une flexibilité encore plus grande :

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(`Blood test result: ${result?.isHealthy ? "Healthy" : "Needs attention"}`);

Étendre des classes génériques

Les classes génériques peuvent aussi être étendues, ce qui permet des implémentations plus spécialisées :

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();

Paramètres de type par défaut

Tout comme les fonctions peuvent avoir des paramètres par défaut, les classes génériques peuvent avoir des types par défaut pour leurs paramètres de type :

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}, "Sector 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}, "Central Lake");

Propriétés statiques dans les classes génériques

Il est important de comprendre que les paramètres de type dans les classes génériques ne s'appliquent qu'aux propriétés et méthodes d'instance. Les propriétés et méthodes statiques sont partagées entre toutes les instances de la classe, quels que soient leurs paramètres de type :

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

Dans cet exemple, la variable statique totalRegisteredDinos est partagée entre toutes les instances de DinosaurRegistry, quels que soient leurs paramètres de type.

Classes génériques et interfaces génériques

Les classes génériques travaillent souvent avec des interfaces génériques, formant un système de types complet :

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(`Found dinosaur: ${rex?.name}, Species: ${rex?.species}`);

Exemple pratique : système de gestion des actifs de Jurassic Park

Élargissons maintenant nos connaissances des classes génériques en implémentant un système complet de gestion des actifs de 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 with ID ${asset.id} already exists`);
48    }
49
50    this.assets.set(asset.id, {
51      ...asset,
52      lastUpdated: new Date()
53    });
54
55    console.log(`Registered new 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(`Asset with ID ${id} not found`);
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(`Updated status of asset ${asset.name} from "${oldStatus}" to "${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}
117Operational: ${operational} (${Math.round(operational / totalAssets * 100)}%)
118In maintenance: ${maintenance} (${Math.round(maintenance / totalAssets * 100)}%)
119Offline: ${offline} (${Math.round(offline / totalAssets * 100)}%)
120======================================
121Generated: ${new Date().toLocaleString()}
122    `;
123  }
124}
125
126// Using the asset management system with different types
127const vehicleManager = new JurassicParkAssetManager<Vehicle>("Vehicles");
128const facilityManager = new JurassicParkAssetManager<Facility>("Objects");
129const powerManager = new JurassicParkAssetManager<PowerSystem>("Power Supply");
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);

Quand utiliser les classes génériques ?

Les classes génériques sont particulièrement utiles dans les situations suivantes :

  1. Collections et conteneurs de données - lorsqu'il faut stocker et gérer des éléments de différents types
  2. Services et dépôts (repositories) - lors de l'implémentation de patrons de conception qui doivent fonctionner avec différents types de données
  3. Composants réutilisables - lorsqu'on veut créer des composants flexibles utilisables dans différents contextes
  4. Abstractions à différents niveaux - lorsqu'on veut définir des hiérarchies de classes spécialisables pour différents types

Résumé

Les classes génériques sont un outil puissant de TypeScript qui permet de créer un code flexible et sûr en matière de types. Elles offrent un excellent équilibre entre réutilisabilité et sécurité des types, en permettant de créer des composants qui :

  • Peuvent travailler avec différents types de données
  • Conservent toute l'information de type
  • Bénéficient du support du compilateur et de l'IDE (suggestions, vérification de types)
  • Restent lisibles et compréhensibles pour les autres programmeurs

Tout comme à Jurassic Park, où l'infrastructure doit être flexible pour accueillir différentes espèces de dinosaures, les classes génériques nous permettent de construire des systèmes à la fois flexibles et sûrs - la combinaison parfaite pour le développement logiciel moderne.

Dans le prochain exercice, nous verrons comment affiner davantage nos génériques à l'aide de contraintes génériques, afin de garantir que nos classes et fonctions génériques ne fonctionnent qu'avec des types répondant à des exigences précises.

Code de cette leçon : index.ts
1// Classes génériques - Parc Jurassique
2console.log("=== CLASSES GÉNÉRIQUES ===\n");
3
4// ===========================================
5// 1. GenericRepository<T> - repozytorium danych
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()} enregistrements`;
47  }
48}
49
50// ===========================================
51// 2. Collection<T> - collection avec itération
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> - paire de valeurs
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>("Dinosaures");
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>("Enclos");
134encRepo.add({ id: 101, name: "Zone T-Rex", capacity: 2 });
135encRepo.add({ id: 102, name: "Vallée des Raptors", capacity: 6 });
136
137console.log(dinoRepo.toString());
138console.log(encRepo.toString());
139
140console.log("\nDinosaur #2:", dinoRepo.getById(2)?.name);
141console.log("Lourds (>1000kg):", dinoRepo.find(d => d.weight > 1000).map(d => d.name).join(", "));
142
143// --- Collection ---
144console.log("\n--- Collection avec 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("Noms courts:", names.toArray().join(", "));
151console.log("Premier:", names.first());
152console.log("Dernier:", names.last());
153
154// --- Pair ---
155console.log("\n--- Pair ---");
156const dinoEnclosure = new Pair<string, string>("Rexy", "Zone T-Rex");
157console.log("Paire:", dinoEnclosure.toString());
158
159const swapped = dinoEnclosure.swap();
160console.log("Échangée:", swapped.toString());
161
162const idWeight = new Pair<number, number>(1, 8000);
163console.log("ID-Poids:", idWeight.toString());
164
165console.log("\nLes classes génériques garantissent la sécurité des types!");

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quand vaut-il mieux utiliser un générique <T> plutôt qu'un type union (string | number) ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un dépôt de données générique pour le parc

  • Tri vertical

    Ordonne les modificateurs d'accès du plus restrictif au plus large :

Articles utiles