Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript typing for decorator middleware patterns

I am thinking about Node middlewares (in various frameworks). Often middlewares will add a property to a request or response object, which is then available for usage by any middlewares registered after it.

One issue with this model is that you don't get effective typing.

To demonstrate, here's a highly simplified model of such a framework. In this case, everything in synchronous, and each middleware receives a Request and must also return one (potentially altered).

interface Req {
    path: string;
    method: string;
}

type Middleware = (request: Req) => Req;

class App {

    mws: Middleware[] = [];

    use(mw: Middleware) {
        this.mws.push(mw);
    }

    run(): Req {

        let request = {
            path: '/foo',
            method: 'POST'
        }
        for(const mw of this.mws) {
           request = mw(request);
        }

        return request;

    }

}

To demonstrate the application, I'm defining 2 middlewares. The first one adds a new property to Req and the second one uses that property:

const app = new App();

app.use( req => {
  req.foo = 'bar';
});

app.use( req => {
  console.log(req.foo);
}

Typescript playground link

This is a very common pattern in server-side Javascript frameworks. Unaltered this throws an error:

Property 'foo' does not exist on type 'Req'.

The standard solution to this is using interface declaration merging:

interface Req {
    path: string;
    method: string;
}
interface Req {
    foo: string;
}

This has drawbacks though. This globally makes the change to the types, and we're actually lying to typescript. The Req type will not have the .foo property until the first middleware is called.

So, I think this is just 'the way it is' in Express, Koa, Curveball, but I'm wondering if a different pattern is possible if we wrote a new framework.

I don't think it's possible to implement this pseudo code example:

app.use(mw1); // Uses default signature
app.use(mw2); // Type of `req` is now the return type of `mw1`.

I'm thinking it might be possible with chaining:

app
  .use(mw1)
  .use(mw2);

But I'm having a really hard time fully grasping how. Plus it gets even more complex if there is a next function, but lets leave that out for the sake of this question.

More broadly, my question is ... what would a great server-side middleware-based framework look like, when it's written specifically with Typescript in mind. Alternatively, maybe there are other patterns that are even more effective and I'm just too blind to see it.

like image 643
Evert Avatar asked Sep 01 '25 20:09

Evert


1 Answers

Interesting question, I don't have a full answer but I think there are some ways to approach this.

First approach

If you'd define middleware a bit more generic, you could just say it's a simple function:

type Middleware<In, Out> = (input: In) => Out

In an HTTP environment you would use some sort of context that contains the request data as input:

interface InitialContext {
  method: 'GET' | 'POST'
  path: string
  query?: Record<string, string>
  headers?: Record<string, string>
}

I would say the first middleware element should transform the InitialContext to something else. If you want to be flexible, it could be anything. More practically, you'd probably want to extend InitialContext. For now I'll just stick with the idea that middleware can transform the InitialContext into anything, and the next middleware needs to deal with that.

An approach could be to just create a function that takes in a number of middleware functions and return the result of the final piece of middleware. You'd just add as many pieces of middleware as you need:

function requestHandler< 
  M1, 
  M2,
  M3,
  M4,
>(
  context: InitialContext,
  middleware1?: Middleware<InitialContext, M1>,
  middleware2?: Middleware<M1, M2>,
  middleware3?: Middleware<M2, M3>,
  middleware4?: Middleware<M3, M4>,
) {
  if (!middleware1) return context
  if (!middleware2) return middleware1(context)
  if (!middleware3) return middleware2(middleware1(context))
  if (!middleware4) return middleware3(middleware2(middleware1(context)))
  return middleware4(middleware3(middleware2(middleware1(context))))
}

This is limited to 4, but could of course be extended to any finite number using some code generation. It works:

const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`

const response = requestHandler({
  method: 'GET',
  path: '/blabla'
}, m1, m2, m3)

The nice thing about this approach is that if your second piece of middleware doesn't work with the output of your first, it'll tell you about it. And this error will be pretty clear.

The downside, apart from having to write out each middleware argument separately, is that the output type is unknown. This is hard to solve if you don't know upfront how many pieces of middleware will be injected.


Second approach, 'reducing' middleware

So, the next question could be: is it possible to determine the 'total' type of N pieces of middleware, combined. Looking back at our previous pieces of middleware:

const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`

You could say the combined type of these 3 pieces is Middleware<InitialContext, string> because the first function has InitialContext as input and the last one has string as output.

Combining them would be possible with a function that looks like this:

function reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T) {
  return middleware.reduce((a, b) => {
    return ((input: any) => b(a(input)))
  }) as any
}

Can we give this function an output type?

We could combine two middleware types together using infer and a conditional type:

type TwoIntoOne<T> = T extends [Middleware<infer A, any>, Middleware<any, infer B>] ? Middleware<A, B> : unknown

Testing this with the following:

type Combined = TwoIntoOne<[Middleware<string, number>, Middleware<number, Date>]>

Will give us (input: string) => Date or Middleware<string, Date> as output.

However, we need to combine not 2 but N pieces of middleware. That means we need either some sort of loop, or we reduce the types together. Reducing can be achieved using recursion, and recursion is something that Typescript types do support!

So, we could use recursion to make some type that combines N types into a single type:

type Combine<T> = T extends [Middleware<infer Ain, infer Aout>, Middleware<infer Aout, infer Bout>, ... infer Rest] ? 
  Combine<[Middleware<Ain, Bout>, ...Rest]> : T

This final type will be wrapped in an array, we can make a simple adjustment to unwrap it:

type Unwrap<T> = T extends [infer A] ? A : unknown

type Combine<T> = T extends [Middleware<infer Ain, infer Aout>, Middleware<infer Aout, infer Bout>, ... infer Rest] ? 
  Combine<[Middleware<Ain, Bout>, ...Rest]> : 
  Unwrap<T>

We can use this generic in our function that we made earlier:

function reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T): Combine<T> {
  return middleware.reduce((a, b) => {
    return ((input: any) => b(a(input)))
  }) as any
}

However, note that internally this function is not type safe. We have to overwrite the result with any to be able to run this without compiler errors. However, doing that allows us to have type-safety in the rest of our code:

const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`

const combined = reduceMiddleware(
  m1, m2, m3
) 

// Calculated type:
// const combined: Middleware<InitialContext, string>

We could simply use this middleware directly with some context as input:

const output = combined({
  method: 'GET',
  path: '/blabla'
})

Or we could set up some bigger application that uses this single piece of middleware to handle a request/response cycle.

Note that although this calculated type will check if the input/output of the middleware matches up, it will not really give you any error message. It'll give you the unknown type and that will probably give you some type error when you're using the combined function. There are not really any type hints to help you solve this, so there's definitely ways to improve.


So to answer your question: yes, there are options to work with middleware in a more type-safe way. Combining this in an OOP approach can be tricky, but working with a single piece of middleware instead of N already makes this a bit more doable.

A very simple approach would just be to accept one piece of middleware in the constructor and expect the user to use the middleware reducer as a separate step:

const combined = reduceMiddleware(
  m1, m2, m3
) 

class App<In, Out> {
  middleware: Middleware<In, Out>

  constructor(middleware: Middleware<In, Out>) {
    this.middleware = middleware
  }
}

const app = new App(combined)
// Calculated type:
// const app: App<InitialContext, string>
like image 142
Bram Avatar answered Sep 03 '25 15:09

Bram