Skip to content

ReturnType not being evaluated when used in function definition generics alias #41391

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
carlos-algms opened this issue Nov 3, 2020 · 20 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@carlos-algms
Copy link

carlos-algms commented Nov 3, 2020

Scenario:

Given an indexed object of functions that return different value types, generate a new object with the same indexes and the return value of the functions.

const given = {
  foo: () => 1,
  bar: () => 'milka',
};

const generatedResult = {
  foo: 1,
  bar: 'milka',
};

Failing example:

TypeScript is not re-evaluating the Types when calling the function with predictable values:
fail
It can predict the functions and their return types, but it is not evaluating ReturnType<...>

Workaround example:

The proper types are correctly inferred when using an anonymous type directly in the return type of the function:
pass

This workaround is not desired given the need for using the Type definition inside the body of the function.

TypeScript Version: 4.0.3

Search Terms:

  • ReturnType
  • Generics

Code

function extract<
  P extends string,
  M extends Record<P, () => any>,
  Ret extends {
    [K in P]: ReturnType<M[K]>;
  }
>(_map: M): Ret {
  return <Ret>{};
}

const { foo, bar } = extract({
  foo: () => 1,
  bar: () => 'bar',
});

Expected behavior:
Variable foo should be of type number
Variable bar should be of type string

Actual behavior:
All variables are of type any

Playground Link:
Playground Link or a CodeSandbox Link - Both with the same behavior.

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Nov 3, 2020

Why do you need so many extra type params?

This is more than enough,

function extract3<M extends Record<string, () => any>> (_map : M) : {
  [k in keyof M] : ReturnType<M[k]>
} {
  return {} as any;
}

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Nov 3, 2020

If we change your example a little, you'll see that your initial extract doesn't really make sense.

image

Notice K and P are string

Playground


image

Notice M[K] is unknown

Playground


ReturnType<unknown> is then any

@carlos-algms
Copy link
Author

carlos-algms commented Nov 4, 2020

@AnyhowStep In a strict environment with enforced types, and no-unsafe-any enabled, those examples are not valid.
Also, in a strict environment, you should not typecast something to any just to return it.

Your example will only return string types, while every key has its own type.
In my case foo is a number, bar is a string.

Looking at your screenshots, both are strings, what is not correct.

{ [x: string]: string; } // from your screenshot, not the actual real type.

The expected behavior is:

type Parameter = {
  foo: () => number;
  bar: () => string;
};

type ActualDesiredReturn = {
  foo: number;
  bar: string;
};

And it is useful to have a reference to the return type for the same reason as any other type in the generics, reuse the constructed type elsewhere in the function body or pass it to another place.

@MartinJohns
Copy link
Contributor

Also, in a strict environment, you should not typecast something to any just to return it.

That is just done to focus on the types, instead of having to create the implementation of the function.


I don't see the reason for the complexity. What you want can trivially be written as:

type DesiredReturn<T extends Record<any, () => any>> = { [P in keyof T]: ReturnType<T[P]> }

// Type is { foo: number; bar: string; }
type ParameterReturn = DesiredReturn<Parameter>;

function extract<T extends Record<any, () => any>>(parameter: T): DesiredReturn<T> { /* implementation be here */ }

const { foo, bar } = extract({ foo: () => 0, bar: () => '' });

@carlos-algms
Copy link
Author

I'm aware of alternatives, but none of the already suggested ones solves the problem or prove that it is not an issue.

Lets see a more complex example:

const randomFn = <T>(): T | null => null; 

export default function makeSelector<T>(context: T) {
  return <
    Keys extends keyof Selectors,
    Selectors extends Record<string, (context: T) => unknown>,
    TRet extends { [K in Keys]: ReturnType<Selectors[K]> }
  >(
    selectors: Selectors,
  ): TRet => { // HERE if I replace the named type TRet with { [K in Keys]: ReturnType<Selectors[K]> } it magically works 🤷🏻‍♂️
    // actual implementation
    // data fetching and parsing and then pass it to another fn
    const ret = randomFn<TRet>();
    return ret;
  };
}

const selector = makeSelector({ unrelated: true, nothingSpecial: false });

const { foo, bar } = selector({ foo: () => 0, bar: () => '' });

As explained in the issue description, if I delete the TRet definition and just put { [K in Keys]: ReturnType<Selectors[K]> } as the return type, it magically works.

another playground with a working example.

@MartinJohns
Copy link
Contributor

Your code is failing because TypeScript can't infer the type of TRet from anything. It only knows what it should extend, but it doesn't know what the type actually looks like.

That's why it works if you remove the type parameter and actually provide the return type. This is working as intended.

@carlos-algms
Copy link
Author

It is able to figure it out.
If you hover your mouse at selector({ foo: () => 0, bar: () => '' }); you will see all the parameters, functions, Keys and return types are there.
image

Everything is there, except for the TRet, that depends on ReturnType.

@MartinJohns
Copy link
Contributor

MartinJohns commented Nov 4, 2020

It is able to figure it out.

Not fully, that's why you get the any. It can't figure out the type of the properties. It knows what the types should extend, but not what the types are. If you provide the type arguments yourself (so no inference is necessary) you get the desired result. With your initial example:

function extract<
  P extends string,
  M extends Record<P, () => any>,
  Ret extends {
    [K in P]: ReturnType<M[K]>;
  }
>(_map: M): Ret {
  return <Ret>{};
}

const { foo, bar } = extract<
  'foo' | 'bar',
  { foo: () => number, bar: () => string },
  { foo: number, bar: string }
>({
  foo: () => 1,
  bar: () => 'bar',
});

Look at the code of extract. Based on what information would the compiler be able to infer the exact type of Ret? There's simply not enough information available. It's not even related to ReturnType<>. If you remove it and just use M[K] you get the same behavior (well, almost, it's unknown instead of any).

@carlos-algms
Copy link
Author

carlos-algms commented Nov 4, 2020

But then, why does it work if I remove the named TRet generic and just put the anonymous:
{ [K in Keys]: ReturnType<Selectors[K]> } as the return of the function?

Why does the named one fail while the anonymous work? anonymous and named have the same signature.

Both of the information will be lazy-inferred only at the function call, not at the creation.

@MartinJohns
Copy link
Contributor

MartinJohns commented Nov 4, 2020

Because then there is no type to infer. The compiler knows the exact type. The compiler doesn't need to figure out "what is the type, and does it extend this other type".

Why does the named one fail while the anonymous work?

You're assuming TRet just means "I have a named type", but that's not the case. You're having a generic type argument, so the method can accept different types for TRet (they just must extend a specific type). Unless explicity provided (see my example) it must be inferred by the compiler. And for this inferrence there's not enough information available.

@carlos-algms
Copy link
Author

I might be underestimating it,
But if I look again at the screenshot of the Hover over the extract function.
Every single type is there, the properties, the functions, and the return type of the given functions.
I'm still not following why only the part where ReturnType is used is unable to infer the type.
The compiler is literally showing every single piece of information in the screenshot.

@MartinJohns
Copy link
Contributor

Every single type is there, the properties, the functions, and the return type of the given functions.

The first two type arguments can easily be inferred from your input argument (both P and M are used as part of _map). But where should the third type argument be inferred from? The third type argument must be inferred from the value you return within your function, and there's not enough information available for this.

I'm still not following why only the part where ReturnType is used is unable to infer the type.

It's unrelated to ReturnType. Remove it, just use M[K] and you have the same issue.

@carlos-algms
Copy link
Author

carlos-algms commented Nov 4, 2020

It's unrelated to ReturnType. Remove it, just use M[K] and you have the same issue.

I see your point now.

But how is it different, if we move the definition from the generic argument to the return type?
It will depend on the parameter to infer the types, right?

@MartinJohns
Copy link
Contributor

But how is it different, if we move the definition from the generic argument to the return type?

Because then there is no type inference necessary anymore. The compiler doesn't need to figure out what Ret ist, it knows it's this specific type.

The difference is:

  • With the generic return type Ret: Compiler needs to figure out, what is Ret and does it extend { [K in P]: M[K] }?
  • With the non-generic return type { [K in P]: M[K] }: Compiler doesn't need to figure out the return type. It's { [K in P]: M[K] }.

@carlos-algms
Copy link
Author

carlos-algms commented Nov 4, 2020

Nice, thanks for the patience and the extremely detailed explanation.

What we do now?
Is it a feature-request then? instead of a bug?

Or, please god no, is it a Wont-do?
because it is extremely useful to have it inside a Closure and being able to pass this generic argument deeper.

Especially when we are working with selectors like @fluentui/react-context-selector that is always returning any while calling useContextSelectors(Context, { foo: () => 0, bar: () => '' })

We want to get derived data from the Context, like sums, Dates from a string, etc., not only properties existent in the Context.

@MartinJohns
Copy link
Contributor

MartinJohns commented Nov 4, 2020

It sounds to me that you don't actually want a generic type, but you just want to reference your rather large type using a local type alias. For this there's #40780 / #30979. But there hasn't been any progress, and likely there won't be in any foreseeable future.

For now you should simply just use { [K in P]: ReturnType<M[K]> } as the return type.

@AnyhowStep
Copy link
Contributor

Looking at your screenshots, both are strings, what is not correct.

{ [x: string]: string; } // from your screenshot, not the actual real type.

I know. I'm saying that you've structured your types in a way that doesn't make sense.
I'm showing you examples of why your attempts do not make sense.

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Nov 4, 2020

Having type parameters that cannot be inferred/constrained from the function parameters is also actually an anti-pattern.

Assume your desired type parameter Ret actually works. We have this problem,

function extract<
  M extends Record<string, () => any>,
  Ret extends {
    foo : number,
    bar : string,
  }
> (_map : M) : Ret {
  return {} as any;
}

const obj = {
  foo: () => 1,
  bar: () => 'bar',
} as const
const { foo, bar } = extract<
  typeof obj,
  {
    foo : 32,
    bar : "lol",
  }
>(obj);

Playground

image

Notice that even though obj returns 1, and "bar", the user can mess things up by setting Ret to be a subtype of { foo : number, bar : string }.

You pointed out that one "should not typecast something to any just to return it".
However, your Ret type parameter is exactly that.
It is a "hidden" cast to practically any data type one wants.

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Nov 4, 2020
@RyanCavanaugh
Copy link
Member

Doesn't seem like there's a bug here. Thanks for the good discussion everyone.

@typescript-bot
Copy link
Collaborator

This issue has been marked as 'Question' and has seen no recent activity. It has been automatically closed for house-keeping purposes. If you're still waiting on a response, questions are usually better suited to stackoverflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

5 participants