Search code examples
typescriptvariadic-functions

How do I pass a type based on a variadic type in TypeScript?


TypeScript playground link

I'm creating a route handler creator for Express for a project, and I'm trying to make it so that you can pass arbitrary assertions as initial arguments before passing the route handler callback. Something like this:

const myHandler = makeHandler(assertion1(), assertion2(), (data, req, res, next) => {
  // data is an array of the results of the assertions
});

I can get some version of the way I want the types "working":

// express namespace omitted for brevity; see playground link above.

type Assertion<T = unknown> = (req: express.Request) => T;
type ReturnTypes<A extends ReadonlyArray<Assertion>> = {
  [K in keyof A]: ReturnType<A[K]>;
};

function assertion1<T extends object>(arg: T) {
  return () => arg
}

function assertion2() {
  return () => "yes"
}

const a = assertion1({ something: "yes" })
const b = assertion2()

// This type works as expected: it is [{ something: string }, string]
type d = ReturnTypes<[typeof a, typeof b]>

However, when I try to get it working as a variadic version of the above for the arguments of makeHandler, something doesn't seem to work and the type of data in the example above is unknown[]:

// the logic for `makeHandler` is omitted for brevity

declare function makeHandler<
 Assertions extends Assertion<unknown>[]
>(...assertionsAndCb: [...Assertions, HandlerCb<ReturnTypes<Assertions>>]): void

// `data` here doesn't seem to be typed correctly. For some reason it's of type unknown[], rather than 
// the same as type `d` above.
makeHandler(assertion1({ hey: "what"}), assertion2(), (data, req) => {
  return { response: {} }
})

I've read a bit about how this might work for something like zip (and heavily based my function on this gist), but I'm struggling to get the actual types to correctly pass. Is there something that I'm missing here - some generic that I'm not correctly allowing to be inferred, for example?


Solution

  • The basic issue here is that TypeScript can't always simultaneously infer both generic type arguments and the contextual type of callback parameters, especially when these appear to the compiler to be circularly dependent. There is an open issue at microsoft/TypeScript#47599 about it. And improvements have been made (for example, microsoft/TypeScript#48538), but it will probably never be completely "solved", since fundamentally TypeScript's inference algorithm isn't a full unification algorithm and doesn't intend to be. Maybe in some future version your code above will suddenly start working as intended. But until and unless that happens you'll have to work around it.

    In particular, you seem to be trying to get simultaneous generic and contextual typing done within a tuple with a leading rest element and there have been some weird issues there, such as in microsoft/TypeScript#47487. That one is closed as the example given there started working in TypeScript 5.1, so it's not exactly the same. But if you open a bug report or feature request for this, it might have a similar fate.


    For your example as written, I'd be inclined to try to use a "reverse mapped type", which is where a generic function uses a homomorphic mapped type (see What does "homomorphic mapped type" mean?) as the parameter type. So if the generic type parameter is T you infer from {[K in keyof T]: F<T[K]>}. Like this:

    declare function makeHandler<T extends any[]>(
      ...a: [...{ [I in keyof T]: Assertion<T[I]> }, HandlerCb<T>]
    ): void;
    

    That will make it easier to infer T, the tuple of return types of your Assertions. And then the HandlerCb<T> will not need inference and happen later. Now you'll mostly get the inference you want:

    declare function assertion1<T extends object>(arg: T): (req: Request) => T;
    declare function assertion2(): (req: Request) => string
    
    const a1 = assertion1({ hey: "what" });
    makeHandler(a1, assertion2(), (data) => {
      data;
      //^? [{hey: string}, string]
      return { response: {} }
    })
    

    Well, as long as you don't have even more generic inference happening at the same time:

    makeHandler(assertion1({ hey: "what" }), assertion2(), (data) => {
      data;
      //^? [unknown, string]
      return { response: {} }
    })
    

    Oops, there's an unknown int here. If you look you'll see that {hey: string} is still inferred there, but it happens too late to help with the inference for T. If you need to keep it inline you'll have to start annotating or specifying generic type arguments manually:

    makeHandler(assertion1<{ hey: string }>({ hey: "what" }), assertion2(), (data) => {
      data;
      //^? [{hey: string}, string]
      return { response: {} }
    })
    

    At the end of the day, there are always going to be limitations like this with TypeScript's inference. If your inferences require too many things to happen in a particular order that doesn't happen to match the order TypeScript does things, you'll end up with failed inference somewhere and see unknown or other issues.


    So, after exhausting the inference, you can either start annotating or specifying things as above, or you can refactor your code so that the required inference order matches how TypeScript does things. For example, if you want that last HandlerCb to be inferred after everything else, you can use a builder pattern or currying to make it so that argument isn't passed until after the inference happens. You'd split (x: X, y: Y) => Z into (x: X) => (y: Y) => Z or {doX(x: X): {doY(y: Y): Z}}:

    declare function makeHandlerCurry<T extends any[]>(
      ...a: { [I in keyof T]: Assertion<T[I]> }
    ): (h: HandlerCb<T>) => void;
    
    makeHandlerCurry(assertion1({ hey: "what" }), assertion2())((data) => {
      data;
      //^? [{hey: string}, string]
      return { response: {} }
    })
    

    That works because T is inferred before you even get to a place where HandlerCb exists. You can call makeHandlerCurry(assertion1({ hey: "what" }), assertion2()), save the result to a variable v and then later call v((data) => {}). So there's a clear ordering of how inference operates there.

    This might or might not be useful for your particular use case, but it's at least an avenue to explore if you really prefer not to annotate things.

    Playground link to code