We use cookies to enhance your experience on the site
CodeWorlds

Generic Constraints

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

Limitations of Generic Constraints

Although generic constraints are a powerful tool, they have some limitations:

  1. Incomplete type information at runtime - TypeScript uses generic constraints mainly at compile time. At runtime, type information is erased (type erasure).

  2. 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.

  3. 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:

  1. You need to write functions or classes that work with different types but require those types to have certain common features.

  2. You want to be sure that specific methods or properties will be available in types used with your generic components.

  3. You need to create precise APIs that ensure type safety while maintaining flexibility.

  4. 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.

Go to CodeWorlds