Search code examples
typescripttypescript-generics

How to generically constrain a higher order function's received functions' arguments in TypeScript?


I want to create a function X that accepts an array of functions Y constrained such that their arguments must be a sub type of object. I want the return type of Y to be inferred from Y's arguments. So for example if user passes [(x: {a:1}) => void] then the return type should be literally that.

When I try to create X in TypeScript I always run into contra-variance issues. If I say I want X to only accept functions that are sub types of (input:object) => void then the object there is seen as a contra-variant constraint meaning only super-types of object are permitted. I want the opposite, sub-types permitted.

How can this be achieved? Here's a recent attempt I made for this question:

type InputFunction<$I> = <$Input extends $I>(input: $Input) => void

declare const createObjectAccepingFunctions: <const $Fs extends InputFunction<object>[]>(fs: $Fs) => { items: $Fs }

const result = createObjectAccepingFunctions([
    (_: { a: 1 }) => {}, // Should pass
    (_: string) => {}, // Should fail
])

result.items

Solution

  • You can't represent the restriction that a function's parameter must be a subtype of object as a straightforward generic constraint on the function type. As you noted, functions are contravariant in their parameter types (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript) and so an upper bound constraint like F extends ((x: object)=>void)[] will end up looking like a lower bound constraint on the x parameters. And since TypeScript doesn't have built-in support for lower bound constraints (as requested in microsoft/TypeScript#14520), you can't write F super ((x: object)=>void)[] either. Not that such a constraint would quite work itself, because the return type would then be contravariant. So we'll have to give up on a straightforward constraint, and refactor. There are different possible approaches.


    One is to make the constraint on the array/tuple of function parameters themselves, and then use a mapped array type to represent the actual array of functions. Like this:

    declare const createObjectAccepingFunctions: <const A extends object[]>(
        fs: { [I in keyof A]: (input: A[I]) => void }
    ) => { items: typeof fs }
    

    So when you call createObjectAccepingFunctions([(a: A1)=>{}, (a: A2)=>{}, (a: A3) =>{}]), TypeScript will infer A as [A1, A2, A3] and compare that to object[]. It if succeeds then your call succeeds, otherwise it fails and you'll get an error:

    const result = createObjectAccepingFunctions([
        (_: { a: 1 }) => { },
        (_: { a: 2 }) => { },
    ]) // okay
    
    result.items;
    //     ^? [(input: { a: 1; }) => void, (input: { a: 2; }) => void]
    
    createObjectAccepingFunctions([
        (_: { a: 1 }) => { },
        (_: string) => { },
    ]); // error!
    

    Unfortunately in the error case, inference of A fails and falls back to the constraint object[], so the error message just complains about everything.


    Another approach is to make the function generic in the array of input functions like before, but don't try to constrain in to anything but an array of functions. Then we map over that array, as a validation step. Each element is either a function whose parameter extends object, in which case we leave that element alone, or it isn't one, in which case we replace it with something else that hopefully gives some clue to the user what the problem is:

    declare const createObjectAccepingFunctions: <const F extends ((x: any) => void)[]>(
        fs: { [I in keyof F]: F[I] extends (x: infer A) => void ?
            [A] extends [object] ? F[I] : never :
            never }
    ) => { items: typeof fs }
    

    So for each member of F at element with index I, if the inferred argument A is a subtype of object, then we leave it alone as F[I]. Otherwise we replace it with never, which it will fail to extend. This gives you behavior like:

    const result = createObjectAccepingFunctions([
        (_: { a: 1 }) => { },
        (_: { a: 2 }) => { },
    ]); // okay
    
    result.items;
    //     ^? [(input: { a: 1; }) => void, (input: { a: 2; }) => void]
    
    createObjectAccepingFunctions([
        (_: { a: 1 }) => { },
        (_: string) => { }, // error!
        //~~~~~~~~~~~~~~~~
        // Type '(_: string) => void' is not assignable to type 'never'
    ]);
    

    That's not really very helpful either but at least your error is confined to the bad argument. Unfortunately TypeScript lacks "invalid types" as requested in microsoft/TypeScript#23689 that allow you to customize error messages. Ideally your message would say something like "string is not assignable to object". The closest we can come is to define an Invalid<T> type where we hope that nothing will accidentally be assignable to it, and then we can pass in for T something that looks like a custom error message if you squint at it. Maybe like this:

    interface Invalid<T> {
        msg: T;
    }
    
    declare const createObjectAccepingFunctions: <const F extends ((x: any) => void)[]>(
        fs: { [I in keyof F]: F[I] extends (x: infer A) => void ?
            [A] extends [object] ? F[I] : Invalid<[A, "is not assignable to object"]> :
            never }
    ) => { items: typeof fs }
    

    Now we get the same success behavior, but failures look like

    createObjectAccepingFunctions([
        (_: { a: 1 }) => { },
        (_: string) => { }, // error!
        //~~~~~~~~~~~~~~~~
        // Type '(_: string) => void' is not assignable to type
        // 'Invalid<[string, "is not assignable to object"]>'.
    ]);
    

    That comes as close as I can get to having a meaningful error.

    Playground link to code