-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
232: Generic Constraints with extends Revisited
I was recently working on a telemetry module for a client, and I ran into a situation where I needed to process a variety of different event objects. Each event is different—some are "ClickEvents," some are "PageLoadEvents"—but every single one of them must have a timestamp and a correlationId. If it doesn't have those, the backend rejects it.
The "Property Does Not Exist" Wall
My first instinct was to write a simple helper function to log these events. I started with a basic generic, thinking I'd just let the caller decide what T is. I tried this:
function logEvent<T>(event: T) {
console.log(`Event ${event.correlationId} occurred at ${event.timestamp}`);
}
TypeScript immediately flagged this with a red squiggle. It told me that correlationId and timestamp don't exist on type T. Which makes sense. As far as TypeScript is concerned, T could be a number, a string, or an empty object. I'm asking it to trust me that T has these properties, but TS is designed specifically to not trust me.
Adding the Constraint
To fix this, I need to tell TypeScript: "Look, T can be anything you want, but it must at least satisfy this specific shape." This is where the extends keyword comes back into play as a constraint.
I'll define a base interface for what a "Trackable" event looks like, and then constrain my generic:
interface Trackable {
correlationId: string;
timestamp: number;
}
function logEvent<T extends Trackable>(event: T) {
console.log(`Event ${event.correlationId} occurred at ${event.timestamp}`);
}
Now the error is gone. By using T extends Trackable, I've created a contract. If someone tries to pass an object that's missing the correlationId, TypeScript will catch it at the call site. This is the basic usage of constraints we've touched on before, but here's where things actually get interesting (and annoying).
The Vanishing Type Problem
In my telemetry system, I didn't just want to log the event; I wanted to "enrich" it by adding a processedAt timestamp and then returning it. I tried this:
function enrichEvent<T extends Trackable>(event: T) {
return {
...event,
processedAt: Date.now()
};
}
At first glance, this looks fine. But when I hover over the return type, I see that TypeScript has inferred the return as (T & { processedAt: number }). Wait, that's actually what I want! But let's see what happens when I try to use the result.
interface UserClick extends Trackable {
buttonId: string;
}
const click: UserClick = {
correlationId: '123',
timestamp: 1625000000,
buttonId: 'submit-btn'
};
const enriched = enrichEvent(click);
console.log(enriched.buttonId); // This works!
It works, but I noticed that if I tried to assign enriched back to a variable typed as UserClick, TS would scream at me because UserClick doesn't have processedAt. I've essentially created a new, anonymous type. This is a common friction point: we want the constraint to allow us to access properties, but we also want to maintain the "identity" of the original type T without losing its specific properties during transformation.
Refining the Return with Intersections
If I want to be explicit about the fact that I'm returning the original type T plus some extra metadata, I should be explicit in the function signature. If I just let TS infer it, it can get messy in larger pipelines.
I'll adjust the signature to use an intersection type for the return value. This ensures that whoever calls this function knows they are getting back everything that was in T, plus my new property:
function enrichEvent<T extends Trackable>(event: T): T & { processedAt: number } {
return {
...event,
processedAt: Date.now()
};
}
Now, if I pass in a UserClick, I get back a UserClick & { processedAt: number }. I've successfully used the constraint to ensure the input is valid, and used an intersection to ensure the output doesn't "forget" what the input was. It's a subtle dance, but it's how you keep your types strict without having to cast everything to any when you start manipulating objects.
📋 Practical Task
Build a Generic API Response Wrapper
You are building a utility for a frontend application that wraps raw API data with metadata. Create a function called wrapResponse that takes a generic object as its data payload.
- The Constraint: The data payload must be an object that contains at least one property:
id: string | number. - The Transformation: The function should return a new object containing the original data, plus a
receivedAtproperty (aDateobject) and astatusproperty (a string, e.g., "success"). - The Type Safety: Ensure the return type is an intersection of the generic type
Tand the new metadata properties, so that specific properties of the original object are preserved in the final result.
Test your function by passing in a User object (with an id and username) and verify that you can still access the username on the wrapped result.
There are no comments for now.