How to Cleanly Write a TypeScript Function With Different Callback Overload Parameters

2.4k Views Asked by At

This is an extension of this question

Given this code:

class Animal {
    a: string;
}

class Dog extends Animal {
    b: string;
}

class Foo<T>{}

function test<T,A extends Dog>(animal:A, func: (p: A) => T): T;
function test<T,A extends Animal>(animal:A, func: (p: A) => Foo<T>): Foo<T>;
function test<T,A extends Animal>(animal:A, func: (p: A) => T|Foo<T>): T|Foo<T> {
    return func(animal);
}

is there a cleaner way of writing the overload that doesn't require the A type Parameter? Or maybe a cleaner of writing any of it? Basically the function conditionally calls the given func with the given animal. If given a dog, type T is returned. If given some other animal, a type Foo<T> is returned.

Update 1

I was unable to get @jcalz version to work, but it took me a while to realize it was related to promises, but I'm not sure how to resolve the issue. Below is my "ugly but it works" methodology, and @jalz's "it's nice but it's broke" methodology:

class Animal {
    a: string;
}

class Dog extends Animal {
    b: string;
}

class Foo<T>{ }

function test<T, A extends Dog>(animal: A, func: (p: A) => Promise<T>): Promise<T>;
function test<T, A extends Animal>(animal: A, func: (p: A) => Promise<Foo<T>>): Promise<Foo<T>>;
function test<T, A extends Animal>(animal: A, func: (p: A) => Promise<T> | Promise<Foo<T>>): Promise<T | Foo<T>> {
    return func(animal);
}

const foo: Promise<Foo<string>> = test(new Animal(), (a) => { return Promise.resolve(new Foo<string>()); });
const other: Promise<string> = test(new Dog(), (d) => { return Promise.resolve(d.b); });

type AnimalFunc<T> = {
    (dog: Dog): Promise<T>;
    (animal: Animal): Promise<Foo<T>>;
}

function test2<T>(dog: Dog, func: AnimalFunc<T>): Promise<T>;
function test2<T>(animal: Animal, func: AnimalFunc<T>): Promise<Foo<T>>;
function test2<T>(animal: Animal, func: AnimalFunc<T>): Promise<T | Foo<T>> {
    return func(animal);
}

const foo2: Promise<Foo<string>>  = test2(new Animal(),
    (a) => {
        return Promise.resolve(new Foo<string>());
    }); // Errors: TS2345   Argument of type '(a: any) => Promise<Foo<string>>' is not assignable to parameter of type 'AnimalFunc<string>'.
        // Type 'Promise<Foo<string>>' is not assignable to type 'Promise<string>'.
        // Type 'Foo<string>' is not assignable to type 'string'.TypeScript Virtual Projects    C: \_Dev\CRM\WebResources\webresources\new_\scripts\Payment.ts  498 Active

const other2: Promise<string>  = test2(new Dog(), (d) => { return Promise.resolve(d.b); });
1

There are 1 best solutions below

3
On BEST ANSWER

I understand

the function conditionally calls the given func with the given animal. If given a dog, type T is returned. If given some other animal, a type Foo<T> is returned.

to mean that the parameter func accepts all Animal inputs but will return different types depending on whether its input is a Dog or not. That means I would declare func to of the following overloaded type:

type AnimalFunc<T> = {
    (dog: Dog): T;
    (animal: Animal): Foo<T>;
}

Then, the function test just passes its animal input to its func input and returns whatever type it gets back. To get that to happen, I would declare test like this:

function test<T>(dog: Dog, func: AnimalFunc<T>): T;
function test<T>(animal: Animal, func: AnimalFunc<T>): Foo<T>;
function test<T>(animal: Animal, func: AnimalFunc<T>): T | Foo<T> {
    return func(animal);
}

Hope that helps.


Update 1

@daryl said:

This works for the definition, but not call sites. If I pass in a dog as the first parameter, my function must accept a dog and return an T, else it must accept an animal and return a Foo, At the call sites, it complains the the function isn't returning the other type (T or Foo)

Without knowing all your use cases I can't tell what the best definition would be. If you really have a function of type AnimalFunc<T> it should work:

function func1(dog: Dog): string;    
function func1(animal: Animal): Foo<string>;
function func1(animal: Animal): string | Foo<string> {
  if (animal instanceof Dog) {
    return "woof";
  }
  return new Foo<string>();
};

var dog: Dog = new Dog();
var cat: Animal = new Animal();
var dogTest: string = test(dog, func1);
var catTest: Foo<string> = test(cat, func1);

If you are trying to pass in a different type of function, please spell out the use cases. Thanks.


Update 2

@daryl said:

This works for the definition, but not call sites. If I pass in a dog as the first parameter, my function must accept a dog and return an T, else it must accept an animal and return a Foo, At the call sites, it complains the the function isn't returning the other type (T or Foo)

Okay, I don't think this has much to do with Promises. It looks like you want func to either take a Dog and return a Promise<T>, or take an Animal and return a Promise<Foo<T>>, but not necessarily both. That is, a particular func might only want a Dog and will not accept a Cat. That's not how I understood it originally.

For this case, then I'd say you want to do:

function test3<T>(dog: Dog, func: (dog: Dog) => Promise<T>): Promise<T>;
function test3<T>(animal: Animal, func: (animal: Animal) => Promise<Foo<T>>): Promise<Foo<T>>;
function test3<T, A extends Animal>(animal: A, func: (animal: A) => Promise<T> | Promise<Foo<T>>): Promise<T> | Promise<Foo<T>> {
  return func(animal);
}

Note that the declarations of test3 (the top two lines) are typed for the benefit of the caller, while the implementation (the third line) is typed for the benefit of the implementer. If all you care about is type safety for people calling test3 but are secure enough in your implementation that you don't need TS to verify the types for you, then you can just implement it as:

function test3<T>(dog: Dog, func: (dog: Dog) => Promise<T>): Promise<T>;
function test3<T>(animal: Animal, func: (animal: Animal) => Promise<Foo<T>>): Promise<Foo<T>>;
function test3(animal: any, func: any): any {
  return func(animal); // fine, but even return animal(func) would be accepted here, to disastrous results at runtime
}

The implementation signature with the generic A is about as specific as I think I can get. It accepts any type of animal A for animal, and a function func that will definitely accept animal and return either a Promise<T> or a Promise<Foo<T>>. This is safe enough for calling func(animal), but you could still fool the typechecker by having an implementation like

function test3<T, A extends Animal>(animal: A, func: (animal: A) => Promise<T> | Promise<Foo<T>>): Promise<T> | Promise<Foo<T>> {
  return Promise.resolve(new Foo<T>()); // no error!!
}

which would cause problems with the first overload declaration since it doesn't ever return a Promise<T>.

I hope this has helped.