Search code examples
typescripttype-conversionsignature

Function signature is not automatically widened if passed as an argument


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

Solution

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

    TypeScript playground

    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.