1

I'm not sure about the title, if anyone has anything better let me know.

I have this type:

export type FooMedia = "course" | "article" | "podcast";

And then I have a function that receives a string of that type and based on the string passed have a different return type.

The overloads:


function getFoo(
  id: string,
  type: "article"
): Promise<AxiosResponse<Article, any>>;

function getFoo(
  id: string,
  type: "podcast"
): Promise<AxiosResponse<Podcast, any>>;

function getFoo(
  id: string,
  type: "course"
): Promise<AxiosResponse<Course, any>>;

Function implementation:


function getFoo<T extends FooMedia, R extends Media>(id: string, type: T> {
...
return axios.get<R>(endpoint);
}

But then, when I call it in a function like so:

function bar(type: FooMedia) {
 return getFoo("t", type);
}

I get an error saying

FooMedia is not assignable to parameter of type '"article"'( and the rest of the types, "podcast" and "course").

The intended behaviour is that based on the FooMedia type passed into the call, I get the correct return type.

Is this possible?

3
  • 1
    You probably want one generic call signature instead of multiple overloads (although you can still use an overload to implement the function), like this. If that meets your needs I can write up an answer explaining; if not, what am I missing? Commented Sep 15, 2022 at 13:22
  • @jcalz That was exactly what I was looking for! Amazing. You can write up the answer and I'll mark it as the right one :) Is the keyof and the map the reason this works or could it be done another way? Commented Sep 15, 2022 at 14:05
  • Okay I will write up an answer when I get a chance Commented Sep 15, 2022 at 14:07

2 Answers 2

2

What you'd like to do is make bar() generic in the type of the type parameter, like so:

function bar<K extends FooMedia>(type: K) {
  return getFoo("t", type); // error!
}

But unfortunately TypeScript doesn't really know how to perform the higher order analysis on overloaded functions in order to figure out how to deal with getFoo("t", type) for generic type.

Overloads are good for the situation when you explicitly call it with code statically known to match one of the call signatures. Anything else is pretty much beyond the compiler's abilities.

You can call getFoo("", "article") is fine because the compiler can resolve that to the getFoo(id: string, type: "article"): Promise<Article>; call signature.

But getFoo("", Math.random()<0.5 ? "article" : "course") is not fine. You get an error that no overload matches the call. The compiler doesn't propagate the union type of the second parameter through to a union of call signatures (this is requested at microsoft/TypeScript#14107). And the same problem happens with getFoo("", type) for generic type. Since type isn't known to be exactly one specific acceptable value, the compiler doesn't know which call signature to use, and it gives up.

You can "fix" it and avoid the error in bar() by adding a call signature that works for the union type of the type:

// add this signature
function getFoo(
  id: string, type: FooMedia
): Promise<AxiosResponse<Course | Article | Podcast, any>>;

but TypeScript doesn't synthesize or infer generic call signatures from specific ones so you'd get a union out:

function bar<K extends FooMedia>(type: K) {
  return getFoo("t", type); // no error, but
}

const z = bar("course");
// const z: Promise<AxiosResponse<Course | Article | Podcast, any>>

Overloads are unfortunately not the tool for the job.


If you want bar() to be generic and have it call getFoo(), then getFoo() should also be generic. You can almost always turn an overloaded function call signature set into a single generic one, but in the general case it's kind of annoying. Say you had this:

declare function ovld(x: string, y: number): boolean;
declare function ovld(x: number): string;
declare function ovld(x: boolean, y: string): number;

ovld(3).toUpperCase();
ovld(true, "abc").toFixed(2);

You could refactor to an input-output mapping type like

type CallSignatures =
  { i: [x: string, y: number], o: boolean } |
  { i: [x: number], o: string } |
  { i: [x: boolean, y: string], o: number };

And write a conditional type to extract the output for a given input:

type Out<C extends { i: any[], o: any }, I extends any[]> =
  C extends unknown ? I extends C['i'] ? C['o'] : never : never;

And test it out:

declare function gnrc<I extends CallSignatures['i']>(...args: I): Out<CallSignatures, I>;
gnrc(3).toUpperCase();
gnrc(true, "abc").toFixed(2);

That works, but it's kind of yucky. However in this case we can simplify considerably. All the call signatures look the same except for a single string literal type input parameter. And the output types are also the same except for a type that's a function of that string literal input type. And TypeScript already has great support for a mapping from string literal inputs to arbitrary outputs... it's called an interface:

interface FooMap {
  course: Course,
  article: Article,
  podcast: Podcast
}
type FooMedia = keyof FooMap;

declare function getFoo<K extends FooMedia>(
  id: string,
  type: K
): Promise<AxiosResponse<FooMap[K], any>>;

That's very straightforward. Now, if you want, you can still implement this as an overload, albeit one with a single call signature:

// call signature
function getFoo<K extends FooMedia>(
  id: string,
  type: K
): Promise<AxiosResponse<FooMap[K], any>>;

// implementation
function getFoo(id: string, type: FooMedia) {
  return null!;
}

People do that sort of thing when the compiler can't verify that the implementation matches the call signature, since overload implementations are checked more loosely than regular function implementations. Okay, let's see if bar() works now:

function bar<K extends keyof FooMap>(type: K) {
  return getFoo("t", type);
}

// function bar<K extends keyof FooMap>(
//   type: K): Promise<AxiosResponse<FooMap[K], any>>

The inferred return type is nice and generic now, which means calling bar() should result in the types you want:

const z = bar("course");
// const z: Promise<AxiosResponse<Course, any>>

Hooray!

Playground link to code

Sign up to request clarification or add additional context in comments.

2 Comments

Amazing! Thank you so much for the detailed answer. I just had some one question, about the part where you say: "(...) mapping from string literal inputs to arbitrary outputs ... it's called an interface". Is there an advantage of using interface over type?
I don't think there's much of an advantage one way or the other. You can use an alias of an anonymous object type if you prefer. Most of the time an interface and a type alias of the same structure behave identically; there are some differences but they are certainly out of scope for this question.
0

You need to add an extra declaration for your general form.

Something similar to this:

function getFoo(id: string, type: FooMedia): Promise<AxiosResponse<Media, any>>;

Here's a complete example. I assumed that Media is a union type.

type Article = {}
type Podcast = {}
type Course = {}

type Media = Article | Podcast | Course;

export type FooMedia = "course" | "article" | "podcast";

function getFoo(
  id: string,
  type: "article"
): Promise<Article>;
function getFoo(
  id: string,
  type: "podcast"
): Promise<Podcast>;
function getFoo(
  id: string,
  type: "course"
): Promise<Course>;
function getFoo(id: string, type: FooMedia): Promise<Media>;
function getFoo<T extends FooMedia, R extends Media>(id: string, type: T) {
    return {id} as unknown as R
}

function bar(type: FooMedia) {
 return getFoo("t", type);
}

and the playground

1 Comment

Thanks for the answer, but I still have one question. When calling bar like so, bar("course") the return type will always be Media. That means that the other more specific declarations aren't needed. But what I wanted was based on the type that the return value would match. Say type is course then the return type would be Promise<Course> and not just Media.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.