I'm trying to create a higher-order function that accepts a number of functions with a specific signature. The signature, in this case, is any function that accepts exactly one parameter that implements a certain interface. Say, Foo
:
interface Foo {
foo: string
}
function higherOrderStuff(funs: ((foo: Foo) => void)[]) {
// do higher order stuff
}
However, to facilitate in the type checking, some functions use intersections of the original interface. Like so:
type MyFoo = Foo & {
foo: 'bar'
};
function myFunction(foo: MyFoo) {
// do my stuff
}
Unfortunately, passing these functions to the higher-order wrapper yields an error:
higherOrderStuff([myFunction]); // compiler error
TS2322: Type '(foo: MyFoo) => void' is not assignable to type '(foo: Foo) => void'.
Types of parameters 'foo' and 'foo' are incompatible.
Type 'Foo' is not assignable to type 'MyFoo'.
Type 'Foo' is not assignable to type '{ foo: "bar"; }'.
Types of property 'foo' are incompatible.
Type 'string' is not assignable to type '"bar"'.
And the error is right: Foo
isn't assignable to MyFoo
. In fact, I'm doing the opposite for a reason.
More strangely, if I try something different and pass around anything other than a function, the same scenario works as expected.
function doStuffWithFoos(foos: Foo[]) {
// do stuff
}
const myFoo: MyFoo = { foo: 'bar' };
doStuffWithFoos([myFoo]); // no squiggly line
So, why is it that arguments are correctly widened, while arguments of function arguments are not?
An explicit cast fixes the error, but it seems like it shouldn't be required to me.
higherOrderStuff([myFunction as (foo: Foo) => void]); // this works
The compiler error happens because functions are contravariant in their parameter types, which basically means that if you're passing a function that takes a parameter of type T
, you can't pass a function that takes a parameter of a subtype of T
instead (is supertype is fine though).
As an example in your code, say you implement higherOrderStuff
by passing an object {foo: 'not-bar'}
to each of the functions in funs
:
interface Foo {
foo: string
}
function higherOrderStuff(funs: ((foo: Foo) => void)[]) {
return funs.map(f => f({foo: 'not-bar'}))
}
Since MyFoo
has the narrower type {foo: bar}
due to
type MyFoo = Foo & {
foo: 'bar'
};
you can define a myFunction
that depends on its parameter having this narrower type:
function myFunction(foo: MyFoo) {
const shouldBeBar = foo.foo // inferred type: 'bar'
const barObject = {bar: () => 42}
const barFunction = barObject[shouldBeBar]
return barFunction() // should return 42
}
Everything is still type correct, and because shouldBeBar
can only be bar
, barFunction
will be a function returning 42.
But if you now run
console.log(higherOrderStuff([myFunction]))
it will end up calling myFunction({foo: 'not-bar'})
, which will cause barFunction
to end up undefined
and throw a runtime error.
This is what the type error tries to prevent from happening.
To understand co- and contravariance, a fruit-and-juicer analogy may be helpful.
If you ask for a piece of fruit, it's fine to get a subtype orange, whereas if you ask for an orange, you don't just want to get any sort of fruit. This is covariance.
Now consider you need a generic fruit juicer (i.e. a function from fruit to juice), then it's not okay to get an orange juicer, as you might want to juice a pineapple as well. On the other hand, if you ask for an orange juicer, a generic fruit juicer will do just fine. You could say that the juicer is contravariant in the type of things it juices.