Skip to Content
Course content

232: Generic Constraints with extends Revisited

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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 receivedAt property (a Date object) and a status property (a string, e.g., "success").
  • The Type Safety: Ensure the return type is an intersection of the generic type T and 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.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.