1

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

Zachiah
  • 1,750
  • 7
  • 28
  • 1
    Inference doesn't work backwards through multiple function calls like this. In some sense you are expecting `withB(async (ev) => ({ body: `${ev.b} ${ev.a}` }));` to infer that `ev` is of type with `{a: number}` on it, seemingly from nowhere. Of course you are actually passing the result of that into `withA()`, but the compiler can't use that information to reach into the call to `withB` and infer stuff. There might be a way to rewrite things to work, not sure yet. (The more I investigate, the less possible this looks, sorry ) – jcalz Apr 06 '22 at 17:47
  • 1
    Please consider using conventional names for type parameters; `data` and `old` look like value names, whereas type parameters are conventionally written as single uppercase letters like `D` and `O`, or sometimes (though I don't recommend this) in UpperPascalCase like `Data` and `Old`. – jcalz Apr 06 '22 at 17:48
  • 1
    Blecch, best I can do here is suggest refactoring to compose your middlewares before calling them, as in [this approach](https://tsplay.dev/NdojXw). Does that address the question or am I missing something? – jcalz Apr 06 '22 at 17:57
  • No problem, that's kind of what I assumed. at least it prevents me from adding wrong type information. Yeah I originally did that, but then I thought it would be easier to comprehend with longer names; Noted Yeah that works! Could you write one with a variadic number of arguments that is recursive? I tried doing it but I failed – Zachiah Apr 06 '22 at 18:08
  • Does [this approach](https://tsplay.dev/Wy44Zm) meet your needs? I'm happy to write that in my answer but the question as asked doesn't talk about it; could you [edit] the question to at least allude to a possible variadic composition approach? – jcalz Apr 07 '22 at 13:07
  • Yes! Please do. – Zachiah Apr 07 '22 at 16:28

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360