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
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.