"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.
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.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.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 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);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);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).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());Mimo że ograniczenia generyczne są potężnym narzędziem, mają pewne ograniczenia:
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).
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.
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.
Ograniczenia generyczne warto stosować, gdy:
Potrzebujesz pisać funkcje lub klasy, które działają na różnych typach, ale wymagają, aby te typy miały pewne wspólne cechy.
Chcesz być pewien, że określone metody lub właściwości będą dostępne w typach używanych z twoimi generycznymi komponentami.
Potrzebujesz stworzyć precyzyjne API, które zapewnia bezpieczeństwo typów przy zachowaniu elastyczności.
Implementujesz wzorce projektowe, takie jak repozytoria, fabryki czy serwisy, które powinny działać z różnymi, ale powiązanymi typami danych.
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.