Search code examples
typescripttypescript-types

Why can't typescript contextually infer these middleware types


This code runs exactly as expected, yet typescript doesn't infer the a property in the function, any idea why and how to fix it?

interface RequestEvent<T extends Record<string, string> = Record<string, string>> {
  params: T
}

interface RequestHandlerOutput {
  body: string
}

type MiddlewareCallback<data> = (event: RequestEvent & data) => Promise<RequestHandlerOutput>

type Middleware<data> = <old>(cb: MiddlewareCallback<data & old>) => MiddlewareCallback<old>

const withA: Middleware<{ a: number }> = cb => async ev => {
  return cb({
    ...ev,
    a: 4,
  })
}

const withB: Middleware<{ b: number }> = cb => async ev => {
  return cb({
    ...ev,
    b: 6,
  })
}
(async () => {
console.log(await withA(withB(async (ev) => {
  // FINE
  ev.b;
  // Not FINE
  ev.a

  return {
    body: `${ev.b} ${ev.a}`
  }
}))({
  params: {}
}))})()

ts playground

EDIT: as jcalz pointed out, this is a very difficult problem and simply using a compose function is pretty straight forward. I am fine with other solutions as long as I'm not forced to type out (no pun intended) the previous middleware's types


Solution

  • I don't know that I can find a canonical source for this, but the compiler just isn't able to perform the kind of inference needed for your formulation to work. Contextual typing of callback parameters tends not to reach backward through multiple function calls. For something like

    withA(withB(async (ev) => ({ body: "" })));
    

    the compiler can infer the type of ev contextually from what withB() expects, but it cannot do so from what withA() expects. The generic type parameter for withB() will be inferred because of the withA() call, but it doesn't make it down into the type of ev. So ev will have a b property but no a property, unfortunately.


    Instead of trying to get that working, I'd suggest refactoring so that you don't have nested function calls. That could involve composing withA and withB to something like withAB, and then pass the callback to the composed function. Here's one way to do it:

    const comp2 = <T, U>(mwT: Middleware<T>, mwU: Middleware<U>): Middleware<T & U> =>
      cb => mwT(mwU(cb));
    const withAB = comp2(withA, withB);
    // const withAB: Middleware<{  a: number; } & {  b: number; }>
    withAB(async (ev) => ({ body: `${ev.a} ${ev.b}` }));
    

    If you want to make the composition function variadic, you can do so (although the compiler won't be able to verify that the implementation satisfies the call signature so you'll need a type assertion or something like it):

    type IntersectTuple<T extends any[]> =
      { [I in keyof T]: (x: T[I]) => void }[number] extends
      ((x: infer I) => void) ? I : never;
    
    const comp = <T extends any[]>(
      ...middlewares: { [I in keyof T]: Middleware<T[I]> }
    ): Middleware<IntersectTuple<T>> =>
      cb => middlewares.reduce((a, mw) => mw(a), cb as any); // <-- as any here
    
    const withAB = comp(withA, withB);
    // const withAB: Middleware<{  a: number; } & { b: number; }>
    

    Here I'm using making comp generic in the tuple of Middleware<> type parameter types; so, the call to comp(withA, withB) will infer T as [{a: number}, {b: number}]. The middlewares rest parameter is a mapped tuple type from which T can be inferred. The return type of the function is MiddleWare<IntersectTuple<T>>, where IntersectTuple<T> takes all the elements of the tuple type T and intersects them all together via a technique like that of UnionToIntersection<T> as presented in this question/answer pair.

    Let's just make sure it works as desired for more than two parameters:

    const withC: Middleware<{ c: string }> =
      cb => async ev => cb({ ...ev, c: "howdy" });
    const composed = comp(withA, withB, withC);
    /* const composed: Middleware<{
        a: number;
    } & {
        b: number;
    } & {
        c: string;
    }> */
    

    Looks good!

    Playground link to code