Skip to Content
Course content

237: Typing a Middleware Chain

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

Middleware is a pattern we see everywhere—Express, Koa, Redux, and even some internal pipeline architectures. The goal is usually simple: take a request or a context object, do something to it, and pass it to the next function. But when you try to type this in TypeScript, you quickly run into a wall. Specifically, the "Wall of Missing Properties."

The Naive Approach

Let's say I'm building a small request pipeline. I have a basic RequestContext, and I want a series of middleware functions to enrich it. First, I'll extract a userId from a header, and then I'll fetch the userProfile based on that ID. Here is how I'd start if I wasn't thinking about the types too hard:

type RequestContext = { url: string };

type Middleware = (ctx: RequestContext) => RequestContext;

const authMiddleware: Middleware = (ctx) => {
  return { ...ctx, userId: 'user_123' }; 
};

const profileMiddleware: Middleware = (ctx) => {
  // Error: Property 'userId' does not exist on type 'RequestContext'
  console.log(ctx.userId); 
  return { ...ctx, profile: { name: 'Alice' } };
};

Right there, TypeScript hits me. From the perspective of profileMiddleware, the ctx is just a RequestContext. It has no idea that authMiddleware just added a userId. I could use any or a type assertion (ctx as any), but that defeats the whole purpose of using TypeScript, doesn't it? We'd be flying blind.

Trying to Solve it with Generics

I want the output type of the first middleware to be the input type of the second. This smells like generics. Let's try to redefine our middleware to be flexible about what it receives and what it returns.

type Middleware<TIn, TOut> = (ctx: TIn) => TOut;

const authMiddleware: Middleware<RequestContext, RequestContext & { userId: string }> = (ctx) => {
  return { ...ctx, userId: 'user_123' };
};

const profileMiddleware: Middleware<RequestContext & { userId: string }, RequestContext & { userId: string, profile: any }> = (ctx) => {
  return { ...ctx, profile: { name: 'Alice' } };
};

This works! profileMiddleware now knows exactly what ctx contains. But look at those type definitions. They are hideous. If I change the userId to a number, I have to manually update every single middleware downstream in the chain. This isn't a scalable solution; it's just manual bookkeeping with extra steps.

Building a Type-Safe Compose Function

The real magic happens when we create a compose function. I want to pass a list of middleware and have TypeScript "calculate" the resulting type as it moves through the array. This is tricky because TypeScript arrays usually expect all elements to be of the same type, but our middleware types are evolving.

I'll use a recursive type or a series of overloads to handle this. For a small number of middleware, overloads are the most readable way to achieve this "piping" effect:

function compose<T1, T2, T3, T4>(
  m1: Middleware<T1, T2>,
  m2: Middleware<T2, T3>,
  m3: Middleware<T3, T4>
): Middleware<T1, T4> {
  return (ctx) => m3(m2(m1(ctx)));
}

// Now let's see if the types flow naturally
const pipeline = compose(
  authMiddleware, 
  profileMiddleware,
  (ctx) => {
    // TypeScript now knows 'ctx' has userId AND profile!
    return { ...ctx, timestamp: Date.now() };
  }
);

const finalCtx = pipeline({ url: '/home' });
console.log(finalCtx.profile.name); // Fully typed!

I've just shifted the burden. Instead of typing every single middleware function explicitly, I'm letting the compose function infer the types. Because authMiddleware returns RequestContext & { userId: string }, TypeScript realizes that m2 (the profileMiddleware) must accept that exact type. It's a domino effect of type inference.

Dealing with the "Any" Leak

One thing I noticed while testing this is that if I accidentally pass a middleware that doesn't match the previous one's output, the error messages can get... weird. You'll see things like Type 'X' is not assignable to type 'Y', but the error might be pointed at the compose function rather than the specific middleware that broke the chain.

My advice here? Keep your middleware functions small and pure. The easier it is for TypeScript to see the return value of a single function, the easier it is for the compose chain to track the state. If you find yourself fighting the types, try extracting the logic into a named function with an explicit return type before passing it into compose.




📋 Practical Task

Exercise: Implementing a Data Validation Pipeline

You are building a data processing pipeline for a user registration form. The data starts as a raw object from an HTTP request and needs to be transformed through three stages: Sanitization, Validation, and Enrichment.

Your task is to implement a type-safe pipeline using the compose pattern discussed in the lesson.

  • Stage 1 (Sanitize): Takes { email: string } and returns it as { email: string, sanitized: true }.
  • Stage 2 (Validate): Takes the sanitized object and adds a isValid: boolean property based on whether the email contains an '@' symbol.
  • Stage 3 (Enrich): Takes the validated object and adds a tenantId: string property (use a hardcoded value like 'tenant_01').

Requirements:

  1. Define a Middleware<TIn, TOut> type.
  2. Implement a compose function that handles these three specific stages.
  3. Ensure that the final result of the pipeline is fully typed, meaning you can access .sanitized, .isValid, and .tenantId on the final object without any type casting.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.