JavaScript and TypeScript course Β· Module 7: Advanced TypeScript
Generic Constraints
In this lesson9
"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.
Generic Constraint Basics
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.
Example: Simple Generic Constraint
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.
Constraints on Interfaces and Types
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// });Constraints for Generic Classes
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);Constraints with Multiple Requirements
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);Constraints with Variant Elements (keyof)
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).
Practical Example: Reporting System for Jurassic Park
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=======================\n\n`;
149
150 allResources.forEach(resource => {
151 report += `${resource.name} (ID: ${resource.id}):\n`;
152 report += `${resource.generateReport()}\n\n`;
153 });
154
155 return report;
156 }
157}
158
159// --- Specific repository implementations ---
160
161// Dinosaur repository
162class DinosaurRepository extends ManageableResourceRepository<Dinosaur> {
163 // Methods specific to dinosaurs
164 findBySpecies(species: string): Dinosaur[] {
165 return this.getAll().filter(dino => dino.species === species);
166 }
167
168 findByDiet(diet: Dinosaur["diet"]): Dinosaur[] {
169 return this.getAll().filter(dino => dino.diet === diet);
170 }
171
172 findByEnclosure(enclosureId: string): Dinosaur[] {
173 return this.getAll().filter(dino => dino.enclosureId === enclosureId);
174 }
175
176 // Method generating a special report for dinosaurs
177 generateHealthStatusReport(): string {
178 const allDinos = this.getAll();
179 const healthy = allDinos.filter(d => d.healthStatus === "healthy").length;
180 const sick = allDinos.filter(d => d.healthStatus === "sick").length;
181 const critical = allDinos.filter(d => d.healthStatus === "critical").length;
182
183 return `
184RAPORT ZDROWIA DINOSAURS
185=========================
186Total: ${allDinos.length}
187Zdrowe: ${healthy} (${Math.round(healthy / allDinos.length * 100)}%)
188Chore: ${sick} (${Math.round(sick / allDinos.length * 100)}%)
189Stan critical: ${critical} (${Math.round(critical / allDinos.length * 100)}%)
190=========================
191`;
192 }
193}
194
195// Employee repository
196class EmployeeRepository extends ManageableResourceRepository<Employee> {
197 // Methods specific to employees
198 findByDepartment(department: Employee["department"]): Employee[] {
199 return this.getAll().filter(emp => emp.department === department);
200 }
201
202 findByClearanceLevel(level: Employee["clearanceLevel"]): Employee[] {
203 return this.getAll().filter(emp => emp.clearanceLevel === level);
204 }
205}
206
207// --- Example resource classes with reporting implementation ---
208
209// Dinosaur class implementing Reportable
210class ReportableDinosaur implements Dinosaur, Reportable {
211 id: string;
212 name: string;
213 species: string;
214 diet: "carnivore" | "herbivore" | "omnivore";
215 healthStatus: "healthy" | "sick" | "critical";
216 enclosureId: string;
217 lastUpdated: Date;
218
219 // Additional properties
220 private weight: number;
221 private height: number;
222 private behaviors: string[] = [];
223
224 constructor(
225 id: string,
226 name: string,
227 species: string,
228 diet: "carnivore" | "herbivore" | "omnivore",
229 healthStatus: "healthy" | "sick" | "critical",
230 enclosureId: string,
231 weight: number,
232 height: number
233 ) {
234 this.id = id;
235 this.name = name;
236 this.species = species;
237 this.diet = diet;
238 this.healthStatus = healthStatus;
239 this.enclosureId = enclosureId;
240 this.weight = weight;
241 this.height = height;
242 this.lastUpdated = new Date();
243 }
244
245 addBehaviorObservation(behavior: string): void {
246 this.behaviors.push(`${new Date().toISOString()}: ${behavior}`);
247 this.lastUpdated = new Date();
248 }
249
250 generateReport(): string {
251 return `
252--- RAPORT DINOZAURA ---
253ID: ${this.id}
254Name: ${this.name}
255Species: ${this.species}
256Diet: ${this.diet}
257Stan zdrowia: ${this.healthStatus}
258Zagroda: ${this.enclosureId}
259Weight: ${this.weight} kg
260Height: ${this.height} m
261Ostatnie zachowania: ${this.behaviors.length > 0 ? '\n - ' + this.behaviors.slice(-3).join('\n - ') : 'Brak zapisanych obserwacji'}
262-----------------------
263`;
264 }
265}
266
267// --- Example usage ---
268
269// Creating a repository for reportable dinosaurs
270const reportableDinoRepo = new ReportableResourceRepository<ReportableDinosaur>();
271
272// Adding dinosaurs to the repository
273const rexReport = new ReportableDinosaur(
274 "D-001",
275 "Rexy",
276 "Tyrannosaurus Rex",
277 "carnivore",
278 "healthy",
279 "E-005",
280 8000,
281 5.2
282);
283
284const triceratopsReport = new ReportableDinosaur(
285 "D-002",
286 "Tricy",
287 "Triceratops",
288 "herbivore",
289 "sick",
290 "E-010",
291 6000,
292 3.0
293);
294
295// Adding behavior observations
296rexReport.addBehaviorObservation("Ate entire cow w within 15 minut");
297rexReport.addBehaviorObservation("Terytorialne roar w kierunku zagrody Spinozaura");
298
299triceratopsReport.addBehaviorObservation("Weaker apetyt than zwykle");
300triceratopsReport.addBehaviorObservation("Oznaki fever i drowsiness");
301
302// Adding to repository
303reportableDinoRepo.add(rexReport);
304reportableDinoRepo.add(triceratopsReport);
305
306// Generating reports
307console.log(reportableDinoRepo.generateIndividualReport("D-001"));
308console.log(reportableDinoRepo.generateSummaryReport());Limitations of Generic Constraints
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.
When to Apply Generic Constraints?
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.
Summary
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.
Code for this lesson: index.ts
1// Generic constraints - Jurassic Park
2console.log("=== GENERIC CONSTRAINTS ===\n");
3
4// ===========================================
5// 1. extends - constraining a type
6// ===========================================
7
8interface HasName {
9 name: string;
10}
11
12interface HasWeight {
13 weight: number;
14}
15
16// T must have a 'name' field
17function printName<T extends HasName>(entity: T): void {
18 console.log(`Name: ${entity.name}`);
19}
20
21// T must have both 'name' and 'weight'
22function getHeaviest<T extends HasName & HasWeight>(items: T[]): T | undefined {
23 if (items.length === 0) return undefined;
24 return items.reduce((max, item) => item.weight > max.weight ? item : max);
25}
26
27// ===========================================
28// 2. keyof - safe access to keys
29// ===========================================
30
31interface DinosaurProfile {
32 name: string;
33 species: string;
34 weight: number;
35 height: number;
36 dangerLevel: number;
37}
38
39// K must be a key of T
40function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
41 return obj[key];
42}
43
44// Getting many properties at once
45function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
46 const result = {} as Pick<T, K>;
47 keys.forEach(key => {
48 result[key] = obj[key];
49 });
50 return result;
51}
52
53// ===========================================
54// 3. Constraints with default types
55// ===========================================
56
57interface Sortable {
58 compareTo?(other: this): number;
59}
60
61function sortEntities<T extends HasName>(
62 items: T[],
63 compareFn: (a: T, b: T) => number = (a, b) => a.name.localeCompare(b.name)
64): T[] {
65 return [...items].sort(compareFn);
66}
67
68// ===========================================
69// 4. Constraining the constructor
70// ===========================================
71
72interface Constructor<T> {
73 new (...args: any[]): T;
74}
75
76function createInstances<T extends HasName>(
77 Ctor: Constructor<T>,
78 count: number,
79 ...args: any[]
80): T[] {
81 const instances: T[] = [];
82 for (let i = 0; i < count; i++) {
83 instances.push(new Ctor(...args));
84 }
85 return instances;
86}
87
88// ===========================================
89// Test
90// ===========================================
91
92const dinosaurs: DinosaurProfile[] = [
93 { name: "Rexy", species: "T-Rex", weight: 8000, height: 5.5, dangerLevel: 10 },
94 { name: "Blue", species: "Velociraptor", weight: 15, height: 1.8, dangerLevel: 8 },
95 { name: "Patty", species: "Brachiosaurus", weight: 50000, height: 16, dangerLevel: 1 },
96 { name: "Cera", species: "Triceratops", weight: 6000, height: 3, dangerLevel: 3 },
97];
98
99// extends
100console.log("--- extends constraint ---");
101printName({ name: "Rexy" }); // OK - it has a 'name' field
102// printName({ age: 5 }); // Error! - no 'name'
103
104const heaviest = getHeaviest(dinosaurs);
105console.log(`Heaviest: ${heaviest?.name} (${heaviest?.weight}kg)`);
106
107// keyof
108console.log("\n--- keyof constraint ---");
109const rex = dinosaurs[0];
110console.log("Name:", getProperty(rex, "name"));
111console.log("Weight:", getProperty(rex, "weight"));
112// getProperty(rex, "color"); // Error! 'color' is not a key
113
114const summary = pick(rex, ["name", "species", "dangerLevel"]);
115console.log("Picked fields:", JSON.stringify(summary));
116
117// Sorting with a constraint
118console.log("\n--- sortEntities ---");
119const sorted = sortEntities(dinosaurs);
120console.log("Alphabetically:", sorted.map(d => d.name).join(", "));
121
122const byDanger = sortEntities(dinosaurs, (a, b) => b.dangerLevel - a.dangerLevel);
123console.log("By danger:", byDanger.map(d => `${d.name}(${d.dangerLevel})`).join(", "));
124
125// Multiple constraints
126console.log("\n--- Multiple constraints ---");
127function describeEntity<T extends HasName & HasWeight>(entity: T): string {
128 return `${entity.name} weighs ${entity.weight}kg`;
129}
130dinosaurs.forEach(d => console.log(describeEntity(d)));
131
132console.log("\nConstraints = safe generics with guaranteed types!");Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does the notation <T extends { length: number }> mean in TypeScript?
2. What is a constraint in generics in TypeScript?
Hands-on tasks in the game
- Code editor
Use constraints for type-safe data access
- Horizontal ordering
Arrange the elements of keyof usage:
- Vertical ordering
Order the constraints from most general to most specific: