Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Ograniczenia generyczne (constraints)

"W Parku Jurajskim nie wszystkie dinozaury mogą być umieszczone w tych samych zagrodach," tłumaczy Dr. Alan Grant, główny paleontolog parku. "Roślinożerne triceratopsy mogą żyć razem, ale nie możemy umieścić ich z drapieżnymi welociraptorami. Potrzebujemy jasnych zasad, które dinozaury mogą być gdzie umieszczone."

W TypeScript ograniczenia generyczne (generic constraints) działają na podobnej zasadzie - pozwalają określić wymagania dla typów, które mogą być użyte z generycznymi funkcjami lub klasami. Dzięki nim możemy tworzyć bardziej precyzyjne i bezpieczne API, zapewniając, że tylko odpowiednie typy danych są akceptowane.

Podstawy ograniczeń generycznych

Ograniczenia generyczne tworzymy przy użyciu słowa kluczowego

extends
. Pozwala to na ograniczenie zakresu możliwych typów, które mogą być użyte jako argumenty typu.

Przykład: Proste ograniczenie generyczne

Rozważmy funkcję, która ma wyświetlać podstawowe informacje o dinozaurze. Zamiast akceptować dowolny typ, chcemy mieć pewność, że typ przekazany do funkcji ma przynajmniej właściwości

name
i
species
:

1// Ograniczenie generyczne - typ T musi mieć przynajmniej właściwości name i species
2function displayDinosaurInfo<T extends { name: string; species: string }>(dino: T): void {
3  console.log(`Nazwa: ${dino.name}, Gatunek: ${dino.species}`);
4}
5
6// Działa, ponieważ obiekt zawiera wymagane właściwości
7displayDinosaurInfo({ name: "Blue", species: "Velociraptor", age: 5 });
8
9// Działa również z bardziej złożonymi obiektami, o ile zawierają wymagane właściwości
10displayDinosaurInfo({
11  name: "Rex",
12  species: "Tyrannosaurus Rex",
13  age: 7,
14  weight: 8000,
15  dangerLevel: "Extreme"
16});
17
18// Błąd kompilacji - brak wymaganej właściwości 'species'
19// displayDinosaurInfo({ name: "Charlie", age: 4 });

W tym przykładzie

T extends { name: string; species: string }
oznacza, że typ generyczny
T
musi zawierać co najmniej właściwości
name
i
species
odpowiedniego typu. Możemy przekazać obiekt z dodatkowymi właściwościami, ale te dwie są wymagane.

Ograniczenia na interfejsach i typach

Często definiujemy interfejsy lub typy, a następnie używamy ich jako ograniczeń dla naszych typów generycznych:

1// Definiujemy interfejs podstawowy
2interface DinosaurBase {
3  id: string;
4  name: string;
5  species: string;
6  age: number;
7}
8
9// Funkcja z ograniczeniem generycznym opartym na interfejsie
10function addToRegistry<T extends DinosaurBase>(dino: T): void {
11  console.log(`Dodano do rejestru: ${dino.name} (ID: ${dino.id})`);
12  // Tutaj moglibyśmy dodać dinozaura do bazy danych, itp.
13}
14
15// Działa, ponieważ obiekt implementuje DinosaurBase
16addToRegistry({
17  id: "V-001",
18  name: "Delta",
19  species: "Velociraptor",
20  age: 6,
21  trainingLevel: 3 // Dodatkowa właściwość jest ok
22});
23
24// Błąd kompilacji - brak wymaganej właściwości 'age'
25// addToRegistry({
26//   id: "T-001",
27//   name: "Rexy",
28//   species: "Tyrannosaurus"
29// });

Ograniczenia dla klas generycznych

Ograniczenia generyczne są szczególnie przydatne w klasach generycznych, gdzie chcemy zapewnić, że typy użyte z klasą będą spełniać określone wymagania:

1// Interfejs dla zwierząt, które mogą być monitorowane
2interface Trackable {
3  id: string;
4  lastKnownLocation: { x: number; y: number };
5  attachTracker(trackerId: string): void;
6}
7
8// Klasa generyczna z ograniczeniem
9class DinosaurTracker<T extends Trackable> {
10  private trackedEntities: T[] = [];
11
12  addEntity(entity: T): void {
13    // Możemy bezpiecznie używać metod z interfejsu Trackable
14    entity.attachTracker(`TRK-${Date.now()}`);
15    this.trackedEntities.push(entity);
16    console.log(`Rozpoczęto śledzenie: ID ${entity.id} na pozycji (${entity.lastKnownLocation.x}, ${entity.lastKnownLocation.y})`);
17  }
18
19  updateLocation(id: string, x: number, y: number): void {
20    const entity = this.trackedEntities.find(e => e.id === id);
21    if (entity) {
22      entity.lastKnownLocation = { x, y };
23      console.log(`Zaktualizowano pozycję ${id}: (${x}, ${y})`);
24    }
25  }
26
27  getEntities(): readonly T[] {
28    return [...this.trackedEntities];
29  }
30}
31
32// Implementacja klasy dinozaura, która spełnia wymagania interfejsu Trackable
33class Velociraptor implements Trackable {
34  id: string;
35  name: string;
36  lastKnownLocation: { x: number; y: number };
37  private tracker: string | null = null;
38
39  constructor(id: string, name: string, initialX: number, initialY: number) {
40    this.id = id;
41    this.name = name;
42    this.lastKnownLocation = { x: initialX, y: initialY };
43  }
44
45  attachTracker(trackerId: string): void {
46    this.tracker = trackerId;
47    console.log(`Przypięto tracker ${trackerId} do velociraptora ${this.name}`);
48  }
49
50  hunt(): void {
51    // Logika polowania
52    console.log(`${this.name} poluje w okolicy (${this.lastKnownLocation.x}, ${this.lastKnownLocation.y})`);
53  }
54}
55
56// Użycie trackera z velociraptorami
57const raptorTracker = new DinosaurTracker<Velociraptor>();
58
59const blue = new Velociraptor("V-001", "Blue", 120, 340);
60const charlie = new Velociraptor("V-002", "Charlie", 125, 330);
61
62raptorTracker.addEntity(blue);
63raptorTracker.addEntity(charlie);
64
65// Możemy użyć metod specyficznych dla Velociraptor, ponieważ znamy konkretny typ
66const raptors = raptorTracker.getEntities();
67raptors.forEach(raptor => raptor.hunt());
68
69// Symulacja ruchu
70raptorTracker.updateLocation("V-001", 140, 360);

Ograniczenia z wieloma wymaganiami

Czasami potrzebujemy, aby typ generyczny spełniał wymagania z wielu interfejsów lub typów. Możemy używać znaku

&
(intersection type) do łączenia ograniczeń:

1// Dwa interfejsy reprezentujące różne aspekty dynamiki parku
2interface Identifiable {
3  id: string;
4  serialNumber?: string;
5}
6
7interface Statusable {
8  status: "active" | "inactive" | "maintenance";
9  lastStatusUpdate: Date;
10  updateStatus(newStatus: "active" | "inactive" | "maintenance"): void;
11}
12
13// Funkcja generyczna, która wymaga, aby typ spełniał oba interfejsy
14function performMaintenanceCheck<T extends Identifiable & Statusable>(entity: T): void {
15  console.log(`Rozpoczęcie przeglądu dla ${entity.id}`);
16
17  if (entity.status !== "maintenance") {
18    entity.updateStatus("maintenance");
19    console.log(`Status zaktualizowany do "maintenance" o ${entity.lastStatusUpdate.toLocaleTimeString()}`);
20  }
21
22  // Symulacja przeglądu
23  setTimeout(() => {
24    entity.updateStatus("active");
25    console.log(`Przegląd zakończony dla ${entity.id}. Status: active`);
26  }, 2000);
27}
28
29// Klasa, która implementuje oba interfejsy
30class SecurityGate implements Identifiable, Statusable {
31  id: string;
32  serialNumber: string;
33  status: "active" | "inactive" | "maintenance";
34  lastStatusUpdate: Date;
35
36  constructor(id: string, serialNumber: string) {
37    this.id = id;
38    this.serialNumber = serialNumber;
39    this.status = "active";
40    this.lastStatusUpdate = new Date();
41  }
42
43  updateStatus(newStatus: "active" | "inactive" | "maintenance"): void {
44    this.status = newStatus;
45    this.lastStatusUpdate = new Date();
46
47    // Dodatkowa logika specyficzna dla bramy
48    if (newStatus === "active") {
49      console.log(`Brama ${this.id} jest teraz aktywna i zabezpieczona`);
50    } else if (newStatus === "inactive") {
51      console.log(`UWAGA: Brama ${this.id} jest nieaktywna. Zagrożenie bezpieczeństwa!`);
52    }
53  }
54
55  // Metoda specyficzna dla bramy
56  toggleLock(locked: boolean): void {
57    console.log(`Brama ${this.id} jest teraz ${locked ? "zablokowana" : "odblokowana"}`);
58  }
59}
60
61// Użycie funkcji z obiektem, który spełnia oba ograniczenia
62const mainGate = new SecurityGate("GATE-001", "SN123456");
63performMaintenanceCheck(mainGate);
64
65// Dodatkowa funkcjonalność dostępna dzięki znajomości pełnego typu
66mainGate.toggleLock(true);

Ograniczenia z elementami wariantowymi (keyof)

Operator

keyof
w TypeScript pozwala nam uzyskać sumę kluczy obiektu jako typ. Możemy łączyć to z ograniczeniami generycznymi, aby tworzyć funkcje, które działają na określonych właściwościach obiektów:

1// Funkcja, która pobiera obiekt i nazwę pola, a następnie zwraca wartość tego pola
2function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
3  return obj[key];
4}
5
6// Przykładowy obiekt z danymi dinozaura
7const trex = {
8  name: "Rexy",
9  species: "Tyrannosaurus Rex",
10  age: 7,
11  diet: "carnivore",
12  threatLevel: 9,
13  enclosureId: "E-005"
14};
15
16// Poprawne użycie - 'name' jest kluczem obiektu trex
17const dinoName = getProperty(trex, "name");
18console.log(dinoName);  // "Rexy"
19
20// Poprawne użycie - 'age' jest kluczem obiektu trex
21const dinoAge = getProperty(trex, "age");
22console.log(dinoAge);  // 7
23
24// Błąd kompilacji - 'habitat' nie jest kluczem obiektu trex
25// const habitat = getProperty(trex, "habitat");

To ograniczenie zapewnia, że klucz

K
jest faktycznie nazwą właściwości obiektu
T
. TypeScript wie również, jaki typ wartości zostanie zwrócony:
T[K]
(typ wartości pod kluczem
K
w obiekcie typu
T
).

Praktyczny przykład: System raportowania dla Parku Jurajskiego

Zobaczmy bardziej złożony przykład, który wykorzystuje różne aspekty ograniczeń generycznych w kontekście systemu raportowania dla Parku Jurajskiego:

1// --- Definicje bazowych typów ---
2
3// Bazowy interfejs dla wszystkich zasobów parku
4interface ParkResource {
5  id: string;
6  name: string;
7  lastUpdated: Date;
8}
9
10// Interfejs dla generowania raportów
11interface Reportable {
12  generateReport(): string;
13}
14
15// Interfejs dla zasobów, którymi można zarządzać
16interface Manageable {
17  assignTo(employeeId: string): void;
18  getAssignee(): string | null;
19}
20
21// --- Specyficzne typy zasobów ---
22
23interface Dinosaur extends ParkResource {
24  species: string;
25  diet: "carnivore" | "herbivore" | "omnivore";
26  healthStatus: "healthy" | "sick" | "critical";
27  enclosureId: string;
28}
29
30interface Employee extends ParkResource {
31  role: string;
32  department: "research" | "security" | "operations" | "visitor";
33  clearanceLevel: 1 | 2 | 3 | 4 | 5;
34}
35
36interface Facility extends ParkResource {
37  type: "enclosure" | "lab" | "visitor_center" | "power_plant";
38  status: "operational" | "maintenance" | "construction";
39  location: { x: number; y: number };
40}
41
42// --- Implementacja systemu raportowania ---
43
44// Generyczna klasa bazowa dla wszystkich repozytoriów
45class ResourceRepository<T extends ParkResource> {
46  protected resources: Map<string, T> = new Map();
47
48  add(resource: T): void {
49    this.resources.set(resource.id, {
50      ...resource,
51      lastUpdated: new Date()
52    });
53  }
54
55  get(id: string): T | undefined {
56    return this.resources.get(id);
57  }
58
59  getAll(): T[] {
60    return Array.from(this.resources.values());
61  }
62
63  update(id: string, updates: Partial<T>): boolean {
64    const resource = this.resources.get(id);
65    if (!resource) return false;
66
67    this.resources.set(id, {
68      ...resource,
69      ...updates,
70      lastUpdated: new Date()
71    });
72
73    return true;
74  }
75
76  delete(id: string): boolean {
77    return this.resources.delete(id);
78  }
79
80  count(): number {
81    return this.resources.size;
82  }
83}
84
85// Klasa dla zarządzalnych zasobów - dodaje funkcjonalność przypisywania
86class ManageableResourceRepository<T extends ParkResource & Partial<Manageable>>
87  extends ResourceRepository<T> {
88
89  private assignments: Map<string, string> = new Map(); // resourceId -> employeeId
90
91  assignResource(resourceId: string, employeeId: string): boolean {
92    const resource = this.resources.get(resourceId);
93    if (!resource) return false;
94
95    this.assignments.set(resourceId, employeeId);
96
97    // Jeśli zasób implementuje Manageable, wywołujemy jego metodę assignTo
98    if (resource.assignTo) {
99      resource.assignTo(employeeId);
100    }
101
102    return true;
103  }
104
105  getAssignee(resourceId: string): string | null {
106    // Najpierw sprawdzamy, czy zasób implementuje własną metodę getAssignee
107    const resource = this.resources.get(resourceId);
108    if (resource?.getAssignee) {
109      return resource.getAssignee();
110    }
111
112    // W przeciwnym razie korzystamy z naszej wewnętrznej mapy
113    return this.assignments.get(resourceId) || null;
114  }
115
116  getAssignedResources(employeeId: string): T[] {
117    const assigned: T[] = [];
118
119    this.assignments.forEach((assignedEmployeeId, resourceId) => {
120      if (assignedEmployeeId === employeeId) {
121        const resource = this.resources.get(resourceId);
122        if (resource) assigned.push(resource);
123      }
124    });
125
126    return assigned;
127  }
128}
129
130// Klasa dla zasobów, które mogą generować raporty
131class ReportableResourceRepository<T extends ParkResource & Reportable>
132  extends ResourceRepository<T> {
133
134  generateIndividualReport(id: string): string | null {
135    const resource = this.resources.get(id);
136    if (!resource) return null;
137
138    return resource.generateReport();
139  }
140
141  generateSummaryReport(): string {
142    const allResources = this.getAll();
143
144    let report = `RAPORT ZBIORCZY
145=======================
146Łączna liczba zasobów: ${allResources.length}
147Ostatnia aktualizacja: ${new Date().toLocaleString()}
148=======================
149
150`;
151
152    allResources.forEach(resource => {
153      report += `${resource.name} (ID: ${resource.id}):\n`;
154      report += `${resource.generateReport()}\n\n`;
155    });
156
157    return report;
158  }
159}
160
161// --- Implementacje specyficznych repozytoriów ---
162
163// Repozytorium dinozaurów
164class DinosaurRepository extends ManageableResourceRepository<Dinosaur> {
165  // Metody specyficzne dla dinozaurów
166  findBySpecies(species: string): Dinosaur[] {
167    return this.getAll().filter(dino => dino.species === species);
168  }
169
170  findByDiet(diet: Dinosaur["diet"]): Dinosaur[] {
171    return this.getAll().filter(dino => dino.diet === diet);
172  }
173
174  findByEnclosure(enclosureId: string): Dinosaur[] {
175    return this.getAll().filter(dino => dino.enclosureId === enclosureId);
176  }
177
178  // Metoda generująca specjalny raport dla dinozaurów
179  generateHealthStatusReport(): string {
180    const allDinos = this.getAll();
181    const healthy = allDinos.filter(d => d.healthStatus === "healthy").length;
182    const sick = allDinos.filter(d => d.healthStatus === "sick").length;
183    const critical = allDinos.filter(d => d.healthStatus === "critical").length;
184
185    return `
186RAPORT ZDROWIA DINOZAURÓW
187=========================
188Łącznie: ${allDinos.length}
189Zdrowe: ${healthy} (${Math.round(healthy / allDinos.length * 100)}%)
190Chore: ${sick} (${Math.round(sick / allDinos.length * 100)}%)
191Stan krytyczny: ${critical} (${Math.round(critical / allDinos.length * 100)}%)
192=========================
193`;
194  }
195}
196
197// Repozytorium pracowników
198class EmployeeRepository extends ManageableResourceRepository<Employee> {
199  // Metody specyficzne dla pracowników
200  findByDepartment(department: Employee["department"]): Employee[] {
201    return this.getAll().filter(emp => emp.department === department);
202  }
203
204  findByClearanceLevel(level: Employee["clearanceLevel"]): Employee[] {
205    return this.getAll().filter(emp => emp.clearanceLevel === level);
206  }
207}
208
209// --- Przykładowe klasy zasobów z implementacją raportowania ---
210
211// Klasa dinozaura implementująca Reportable
212class ReportableDinosaur implements Dinosaur, Reportable {
213  id: string;
214  name: string;
215  species: string;
216  diet: "carnivore" | "herbivore" | "omnivore";
217  healthStatus: "healthy" | "sick" | "critical";
218  enclosureId: string;
219  lastUpdated: Date;
220
221  // Dodatkowe właściwości
222  private weight: number;
223  private height: number;
224  private behaviors: string[] = [];
225
226  constructor(
227    id: string,
228    name: string,
229    species: string,
230    diet: "carnivore" | "herbivore" | "omnivore",
231    healthStatus: "healthy" | "sick" | "critical",
232    enclosureId: string,
233    weight: number,
234    height: number
235  ) {
236    this.id = id;
237    this.name = name;
238    this.species = species;
239    this.diet = diet;
240    this.healthStatus = healthStatus;
241    this.enclosureId = enclosureId;
242    this.weight = weight;
243    this.height = height;
244    this.lastUpdated = new Date();
245  }
246
247  addBehaviorObservation(behavior: string): void {
248    this.behaviors.push(`${new Date().toISOString()}: ${behavior}`);
249    this.lastUpdated = new Date();
250  }
251
252  generateReport(): string {
253    return `
254--- RAPORT DINOZAURA ---
255ID: ${this.id}
256Nazwa: ${this.name}
257Gatunek: ${this.species}
258Dieta: ${this.diet}
259Stan zdrowia: ${this.healthStatus}
260Zagroda: ${this.enclosureId}
261Waga: ${this.weight} kg
262Wysokość: ${this.height} m
263Ostatnie zachowania: ${this.behaviors.length > 0 ? '\n  - ' + this.behaviors.slice(-3).join('\n  - ') : 'Brak zapisanych obserwacji'}
264-----------------------
265`;
266  }
267}
268
269// --- Przykład użycia ---
270
271// Tworzenie repozytorium dla raportujących dinozaurów
272const reportableDinoRepo = new ReportableResourceRepository<ReportableDinosaur>();
273
274// Dodawanie dinozaurów do repozytorium
275const rexReport = new ReportableDinosaur(
276  "D-001",
277  "Rexy",
278  "Tyrannosaurus Rex",
279  "carnivore",
280  "healthy",
281  "E-005",
282  8000,
283  5.2
284);
285
286const triceratopsReport = new ReportableDinosaur(
287  "D-002",
288  "Tricy",
289  "Triceratops",
290  "herbivore",
291  "sick",
292  "E-010",
293  6000,
294  3.0
295);
296
297// Dodawanie obserwacji zachowań
298rexReport.addBehaviorObservation("Zjadła całą krowę w ciągu 15 minut");
299rexReport.addBehaviorObservation("Terytorialne ryknięcie w kierunku zagrody Spinozaura");
300
301triceratopsReport.addBehaviorObservation("Słabszy apetyt niż zwykle");
302triceratopsReport.addBehaviorObservation("Oznaki gorączki i senności");
303
304// Dodanie do repozytorium
305reportableDinoRepo.add(rexReport);
306reportableDinoRepo.add(triceratopsReport);
307
308// Generowanie raportów
309console.log(reportableDinoRepo.generateIndividualReport("D-001"));
310console.log(reportableDinoRepo.generateSummaryReport());

Ograniczenia ograniczeń generycznych

Mimo że ograniczenia generyczne są potężnym narzędziem, mają pewne ograniczenia:

  1. Niepełna informacja o typie w czasie wykonania - TypeScript używa ograniczeń generycznych głównie w czasie kompilacji. W czasie wykonania informacje o typach są usuwane (type erasure).

  2. Ograniczenia nominalne vs strukturalne - TypeScript używa typowania strukturalnego, co oznacza, że obiekty o tej samej strukturze są uważane za kompatybilne, nawet jeśli mają różne nazwy typów. Niektórzy programiści przyzwyczajeni do typowania nominalnego (jak w Javie czy C#) mogą uznać to za nieoczekiwane.

  3. Brak ograniczeń negatywnych - Nie można określić, że typ generyczny NIE może mieć pewnych właściwości lub NIE może implementować określonego interfejsu.

Pomimo tych ograniczeń, ograniczenia generyczne są niezwykle przydatne w codziennej pracy z TypeScript.

Kiedy stosować ograniczenia generyczne?

Ograniczenia generyczne warto stosować, gdy:

  1. Potrzebujesz pisać funkcje lub klasy, które działają na różnych typach, ale wymagają, aby te typy miały pewne wspólne cechy.

  2. Chcesz być pewien, że określone metody lub właściwości będą dostępne w typach używanych z twoimi generycznymi komponentami.

  3. Potrzebujesz stworzyć precyzyjne API, które zapewnia bezpieczeństwo typów przy zachowaniu elastyczności.

  4. Implementujesz wzorce projektowe, takie jak repozytoria, fabryki czy serwisy, które powinny działać z różnymi, ale powiązanymi typami danych.

Podsumowanie

Ograniczenia generyczne są ważnym narzędziem w TypeScript, które pozwala na tworzenie bezpiecznych pod względem typów, ale jednocześnie elastycznych komponentów. Pozwalają określić, jakie wymagania muszą spełniać typy używane z generycznymi funkcjami i klasami, co prowadzi do bardziej precyzyjnych i niezawodnych interfejsów API.

Podobnie jak w Parku Jurajskim, gdzie musimy mieć jasne zasady, które dinozaury mogą być umieszczone w których zagrodach, ograniczenia generyczne pozwalają nam jasno określić, jakie typy mogą być używane z naszymi generycznymi komponentami. To zapewnia bezpieczeństwo i przewidywalność naszego kodu, jednocześnie zachowując elastyczność, która sprawia, że generyki są tak potężnym narzędziem.

Przejdź do CodeWorlds