avemike
avemike

Reputation: 117

Inference of union function types

Let's say I have similar architecture to that:

type GetDog = () => { animal: string; bark: boolean };
const getDog: GetDog = () => ({ animal: 'dog', bark: true });

type GetCat = () => { animal: string; meow: boolean };
const getCat: GetCat = () => ({ animal: 'cat', meow: true });

type AnimalFactory =
  | ((callback: GetDog) => ReturnType<typeof getDog>)
  | ((callback: GetCat) => ReturnType<typeof getCat>);

const calmAnimalFactory: AnimalFactory = (callback) => {
  // Some fancy stuff
  return callback();
};

calmAnimalFactory as argument should accept getDog function or getCat function, and then returns corresponding value. The problem is that I guess type inference does not work as I supposed. Callback inside calmAnimalFactory doesn't infer type of GetDog | GetCat, but instead is any. I thought Typescript should determine type of calmAnimalFactory in a way that calmAnimalFactory(getDog) should be typed as

((callback: GetDog) => ReturnType<typeof getDog>)

and calmAnimalFactory(getCat) should be typed as

((callback: GetCat) => ReturnType<typeof getCat>)

I hope it's possible to achieve, but I don't know why it's not working that way.

Upvotes: 1

Views: 84

Answers (2)

jcalz
jcalz

Reputation: 328453

There are a few things going on here. First of all, I think if I have to keep typing typeof getXXX or ReturnType<typeof getXXX>, I will go crazy, so I will define new Dog and Cat interfaces that correspond to what getDog() and getCat() return, and use those exclusively:

interface Dog extends ReturnType<GetDog> { };
interface Cat extends ReturnType<GetCat> { }

You could have and probably should have defined these Dog and Cat types first and then written getCat() and getDog() in terms of them:

interface Dog {
  animal: string;
  bark: boolean;
}

type GetDog = () => Dog;
const getDog: GetDog = () => ({ animal: 'dog', bark: true });

interface Cat {
  animal: string;
  meow: boolean;
}
type GetCat = () => Cat;
const getCat: GetCat = () => ({ animal: 'cat', meow: true });

But either way will work.


Next, if you want an AnimalFactory to return a Cat when you pass it a GetCat parameter, and you want it to return a Dog when you pass it a GetDog parameter, then you want to use an intersection (&) and not a union (|). If you use a union, you're saying that an animal factory will either be something that turns GetCat into a Cat, or it will be something that turns GetDog into a Dog, but not necessarily both. So we want the intersection here:

type AnimalFactory =
  ((callback: GetDog) => Dog)
  & ((callback: GetCat) => Cat);

Next, the compiler cannot reason particularly well about whether or not an unannotated and unasserted function implementation conforms to such an intersection of signatures. Such reasoning would be equivalent to being able to properly type-check overloaded functions, which the compiler simply cannot do (see this comment in microsoft/TypeScript#35338 for example).

So if you just try to contextually type the callback parameter, the compiler will fail, and end up implicitly using any, as you saw.


It would be better to use a generic function implementation that claims to be able to take callback of type () => T for generic T extends Cat | Dog, and then produce a T. That is something the compiler can type check:

const calmAnimalFactory: AnimalFactory =
  <T extends Cat | Dog>(callback: () => T) => {
    return callback();
  };

The compiler is able to verify that a function of type <T extends Cat | Dog>(callback: () => T) => T is assignable to both call signatures of AnimalFactory, and therefore it compiles with no error.


In fact, depending on your use case, you might want to make AnimalFactory as universal as possible by removing the dependency on Cat and Dog, and instead come up with some base type like Animal, such as:

interface Animal {
  animal: string;
}
type UniversalAnimalFactory = <T extends Animal>(callback: () => T) => T;
const universalAnimalFactory: UniversalAnimalFactory = callback => callback();

A UniversalAnimalFactory can do anything that an AnimalFactory can do (GetCat in, Cat out; and GetDog in, Dog out) but it can also do anything else for animals you haven't even thought of yet:

const getFish = () => ({ animal: "fish", swim: true });
universalAnimalFactory(getFish).swim // works

Playground link to code

Upvotes: 3

Linda Paiste
Linda Paiste

Reputation: 42218

You can get proper typing on this using generics. We say that calmAnimalFactory depends on a generic T that represents the callback type. This T must be a function that takes zero arguments and returns anything.

Now we can say that the callback argument for calmAnimalFactory is T and the return type of the function is the same as the return type of the callback.

const calmAnimalFactory = <T extends () => any>(callback: T): ReturnType<T> => {
  // Some fancy stuff
  return callback();
};

const dog = calmAnimalFactory(getDog); // type: { animal: string; bark: boolean; }
const cat = calmAnimalFactory(getCat); // type { animal: string; meow: boolean; }

A slightly different version of the same idea would be to let generic T refer to the type of the created animal. This makes it easier to require that the the callback's return must fit some base Animal interface.

interface Animal {
  animal: string;
}

const calmAnimalFactory = <T extends Animal>(callback: () => T): T => {
  // Some fancy stuff
  return callback();
};

We still get the same types for the cat and the dog.

Typescript Playground Link

Upvotes: 2

Related Questions