JavaScript and TypeScript course Β· Module 7: Advanced TypeScript

Generic Constraints

12 min read
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:

  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.

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. 1. What does the notation <T extends { length: number }> mean in TypeScript?

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

Useful articles