"In Jurassic Park, not all dinosaurs can be placed in the same enclosures," explains Dr. Alan Grant, the park's chief paleontologist. "Herbivorous triceratops can live together, but we can't place them with predatory velociraptors. We need clear rules about which dinosaurs can be placed where."
In TypeScript, generic constraints work on a similar principle - they allow you to specify requirements for types that can be used with generic functions or classes. With them, we can create more precise and safe APIs, ensuring that only appropriate data types are accepted.
We create generic constraints using the
extends keyword. This allows us to limit the range of possible types that can be used as type arguments.Let's consider a function that should display basic information about a dinosaur. Instead of accepting any type, we want to make sure that the type passed to the function has at least the
name and species properties:1// Generic constraint - type T must have at least the name and species properties
2function displayDinosaurInfo<T extends { name: string; species: string }>(dino: T): void {
3 console.log(`Name: ${dino.name}, Species: ${dino.species}`);
4}
5
6// Works because the object contains the required properties
7displayDinosaurInfo({ name: "Blue", species: "Velociraptor", age: 5 });
8
9// Also works with more complex objects, as long as they contain the required properties
10displayDinosaurInfo({
11 name: "Rex",
12 species: "Tyrannosaurus Rex",
13 age: 7,
14 weight: 8000,
15 dangerLevel: "Extreme"
16});
17
18// Compilation error - missing required property 'species'
19// displayDinosaurInfo({ name: "Charlie", age: 4 });In this example,
T extends { name: string; species: string } means that the generic type T must contain at least the properties name and species of the appropriate type. We can pass an object with additional properties, but these two are required.We often define interfaces or types and then use them as constraints for our generic types:
1// Defining a basic interface
2interface DinosaurBase {
3 id: string;
4 name: string;
5 species: string;
6 age: number;
7}
8
9// Function with a generic constraint based on an interface
10function addToRegistry<T extends DinosaurBase>(dino: T): void {
11 console.log(`Dodano do rejestru: ${dino.name} (ID: ${dino.id})`);
12 // Here we could add the dinosaur to the database, etc.
13}
14
15// Works because the object implements DinosaurBase
16addToRegistry({
17 id: "V-001",
18 name: "Delta",
19 species: "Velociraptor",
20 age: 6,
21 trainingLevel: 3 // Additional property is ok
22});
23
24// Compilation error - missing required property 'age'
25// addToRegistry({
26// id: "T-001",
27// name: "Rexy",
28// species: "Tyrannosaurus"
29// });Generic constraints are especially useful in generic classes, where we want to ensure that types used with the class meet specific requirements:
1// Interface for animals that can be tracked
2interface Trackable {
3 id: string;
4 lastKnownLocation: { x: number; y: number };
5 attachTracker(trackerId: string): void;
6}
7
8// Generic class with a constraint
9class DinosaurTracker<T extends Trackable> {
10 private trackedEntities: T[] = [];
11
12 addEntity(entity: T): void {
13 // We can safely use methods from the Trackable interface
14 entity.attachTracker(`TRK-${Date.now()}`);
15 this.trackedEntities.push(entity);
16 console.log(`Started tracking: ID ${entity.id} at position (${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(`Updated position ${id}: (${x}, ${y})`);
24 }
25 }
26
27 getEntities(): readonly T[] {
28 return [...this.trackedEntities];
29 }
30}
31
32// Implementation of a dinosaur class that meets the Trackable interface requirements
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(`Attached tracker ${trackerId} to velociraptor ${this.name}`);
48 }
49
50 hunt(): void {
51 // Hunting logic
52 console.log(`${this.name} poluje w okolicy (${this.lastKnownLocation.x}, ${this.lastKnownLocation.y})`);
53 }
54}
55
56// Using the tracker with velociraptors
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// We can use Velociraptor-specific methods because we know the concrete type
66const raptors = raptorTracker.getEntities();
67raptors.forEach(raptor => raptor.hunt());
68
69// Simulating movement
70raptorTracker.updateLocation("V-001", 140, 360);Sometimes we need a generic type to satisfy requirements from multiple interfaces or types. We can use the
& sign (intersection type) to combine constraints:1// Two interfaces representing different aspects of park dynamics
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// Generic function that requires the type to satisfy both interfaces
14function performMaintenanceCheck<T extends Identifiable & Statusable>(entity: T): void {
15 console.log(`Starting review for ${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 // Simulation of inspection
23 setTimeout(() => {
24 entity.updateStatus("active");
25 console.log(`Review completed for ${entity.id}. Status: active`);
26 }, 2000);
27}
28
29// Class that implements both interfaces
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 // Additional logic specific to the gate
48 if (newStatus === "active") {
49 console.log(`Brama ${this.id} jest teraz aktywna i zabezpieczona`);
50 } else if (newStatus === "inactive") {
51 console.log(`WARNING: Gate ${this.id} is inactive. Security threat!`);
52 }
53 }
54
55 // Method specific to the gate
56 toggleLock(locked: boolean): void {
57 console.log(`Brama ${this.id} jest teraz ${locked ? "zablokowana" : "odblokowana"}`);
58 }
59}
60
61// Using the function with an object that satisfies both constraints
62const mainGate = new SecurityGate("GATE-001", "SN123456");
63performMaintenanceCheck(mainGate);
64
65// Additional functionality available due to knowing the full type
66mainGate.toggleLock(true);The
keyof operator in TypeScript allows us to obtain the union of an object's keys as a type. We can combine this with generic constraints to create functions that operate on specific properties of objects:1// Function that takes an object and a field name, then returns the value of that field
2function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
3 return obj[key];
4}
5
6// Example object with dinosaur data
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// Correct usage - 'name' is a key of the trex object
17const dinoName = getProperty(trex, "name");
18console.log(dinoName); // "Rexy"
19
20// Correct usage - 'age' is a key of the trex object
21const dinoAge = getProperty(trex, "age");
22console.log(dinoAge); // 7
23
24// Compilation error - 'habitat' is not a key of the trex object
25// const habitat = getProperty(trex, "habitat");This constraint ensures that the key
K is actually a property name of the object T. TypeScript also knows what value type will be returned: T[K] (the value type at key K in an object of type T).Let's see a more complex example that uses various aspects of generic constraints in the context of a reporting system for Jurassic Park:
1// --- Base type definitions ---
2
3// Base interface for all park resources
4interface ParkResource {
5 id: string;
6 name: string;
7 lastUpdated: Date;
8}
9
10// Interface for report generation
11interface Reportable {
12 generateReport(): string;
13}
14
15// Interface for manageable resources
16interface Manageable {
17 assignTo(employeeId: string): void;
18 getAssignee(): string | null;
19}
20
21// --- Specific resource types ---
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// --- Reporting system implementation ---
43
44// Generic base class for all repositories
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// Class for manageable resources - adds assignment functionality
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 // If the resource implements Manageable, we call its assignTo method
98 if (resource.assignTo) {
99 resource.assignTo(employeeId);
100 }
101
102 return true;
103 }
104
105 getAssignee(resourceId: string): string | null {
106 // First, we check if the resource implements its own getAssignee method
107 const resource = this.resources.get(resourceId);
108 if (resource?.getAssignee) {
109 return resource.getAssignee();
110 }
111
112 // Otherwise, we use our internal map
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// Class for resources that can generate reports
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=======================
146Total resources: ${allResources.length}
147Last update: ${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// --- Specific repository implementations ---
162
163// Dinosaur repository
164class DinosaurRepository extends ManageableResourceRepository<Dinosaur> {
165 // Methods specific to dinosaurs
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 // Method generating a special report for dinosaurs
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 DINOSAURS
187=========================
188Total: ${allDinos.length}
189Zdrowe: ${healthy} (${Math.round(healthy / allDinos.length * 100)}%)
190Chore: ${sick} (${Math.round(sick / allDinos.length * 100)}%)
191Stan critical: ${critical} (${Math.round(critical / allDinos.length * 100)}%)
192=========================
193`;
194 }
195}
196
197// Employee repository
198class EmployeeRepository extends ManageableResourceRepository<Employee> {
199 // Methods specific to employees
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// --- Example resource classes with reporting implementation ---
210
211// Dinosaur class implementing 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 // Additional properties
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}
256Name: ${this.name}
257Species: ${this.species}
258Diet: ${this.diet}
259Stan zdrowia: ${this.healthStatus}
260Zagroda: ${this.enclosureId}
261Weight: ${this.weight} kg
262Height: ${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// --- Example usage ---
270
271// Creating a repository for reportable dinosaurs
272const reportableDinoRepo = new ReportableResourceRepository<ReportableDinosaur>();
273
274// Adding dinosaurs to the repository
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// Adding behavior observations
298rexReport.addBehaviorObservation("Ate entire cow w within 15 minut");
299rexReport.addBehaviorObservation("Terytorialne roar w kierunku zagrody Spinozaura");
300
301triceratopsReport.addBehaviorObservation("Weaker apetyt than zwykle");
302triceratopsReport.addBehaviorObservation("Oznaki fever i drowsiness");
303
304// Adding to repository
305reportableDinoRepo.add(rexReport);
306reportableDinoRepo.add(triceratopsReport);
307
308// Generating reports
309console.log(reportableDinoRepo.generateIndividualReport("D-001"));
310console.log(reportableDinoRepo.generateSummaryReport());Although generic constraints are a powerful tool, they have some limitations:
Incomplete type information at runtime - TypeScript uses generic constraints mainly at compile time. At runtime, type information is erased (type erasure).
Nominal vs structural constraints - TypeScript uses structural typing, which means objects with the same structure are considered compatible, even if they have different type names. Some programmers used to nominal typing (like in Java or C#) may find this unexpected.
No negative constraints - You cannot specify that a generic type must NOT have certain properties or must NOT implement a specific interface.
Despite these limitations, generic constraints are extremely useful in day-to-day TypeScript work.
Generic constraints are worth applying when:
You need to write functions or classes that work with different types but require those types to have certain common features.
You want to be sure that specific methods or properties will be available in types used with your generic components.
You need to create precise APIs that ensure type safety while maintaining flexibility.
You're implementing design patterns such as repositories, factories, or services that should work with different but related data types.
Generic constraints are an important tool in TypeScript that allows creating type-safe yet flexible components. They let you specify what requirements types used with generic functions and classes must meet, leading to more precise and reliable APIs.
Just like in Jurassic Park, where we need clear rules about which dinosaurs can be placed in which enclosures, generic constraints allow us to clearly define what types can be used with our generic components. This ensures the safety and predictability of our code while maintaining the flexibility that makes generics such a powerful tool.