Repository navigation
JS generic inference change after upgrading to 5.1 #55192
Description
Activity
- addedNeeds InvestigationThis issue needs a team member to investigate its status.This issue needs a team member to investigate its status.
on Jul 31, 2023 sandersn commented
on Jul 31, 2023 MemberMore actionsBroken in 4.8.4.
Specifically, broken in #49740. I'll go look at why.
Some observations given the simplified repro here:
// @filename: second.ts export declare function createSelector<TState, TProps extends any[]>( selector: (s: TState, ...props: TProps) => void, getDependents: (s: TState, ...props: TProps) => void ): void; // @filename: first.js import { createSelector } from './second'; export const getById = createSelector((state) => void 0, (state,id) => void 0,);
- You can tell when inference has gone wrong when the inferred type arguments are
<any, []>not<unknown, [id: any]> - If you make the two parameters of createSelector have the same type, order of the arguments doesn't matter (notice they're reversed from the original repro).
- If you change first.js to first.ts, but explicitly annotate
state: anyso that onlyiduses contextual typing, the result is the same as JS:
// @filename: first.ts import { createSelector } from './second'; export const getById = createSelector((state: any) => void 0, (state: any,id) => void 0,);
- However, if you manually type
/** @type {unknown} */ statein JS, inference still doesn't getTProps=[id: any].
That implies to me that the root of the difference is Javascript's defaulting type parameters without inferences to any instead of unknown -- and that there's still some kind of difference even if you explicitly provide
unknown, probably to do with which function arities are allowed to match.Jake Bailey (@jakebailey) You wrote the original PR. Is this result expected? Unexpected? As far as I can tell it's a pretty straightforward result of deleting some code, but I'm not sure.
Reacted by Noah Allen- You can tell when inference has gone wrong when the inferred type arguments are
defaulting type parameters without inferences to any instead of any
Wait what
edit:
any(JS) instead ofunknown(TS)Jake Bailey (@jakebailey) You wrote the original PR. Is this result expected? Unexpected? As far as I can tell it's a pretty straightforward result of deleting some code, but I'm not sure.
This was a really long time ago, but I don't really understand how the two are related.
It seems like the contextual type of the arrows --
(state, id) => void 0in this case, were used as inference sources for the rest parameter, resulting in[id: any]. Now that doesn't seem to happen in JS, though I haven't figured out why yet. But it seems related to the PRs title, at least, of not inferring rest types with assignContextlParameterTypes.The workaround I finally found was to add another type parameter:
export declare function createSelector<TState, TProps extends any[], TDepProps extends TProps>( selector: (s: TState, ...props: TProps) => void, getDependents: (s: TState, ...props: TDepProps) => void ): void;
I think this makes TS infer the type of
TPropsbased on the arguments toselector, ignoringgetDependentsReacted by Nathan Shively-Sanders- addedRescheduledThis issue was previously scheduled to an earlier milestoneThis issue was previously scheduled to an earlier milestone
on Mar 4, 2024 Andarist commented
on Aug 15, 2024 ContributorMore actionsThis issue is specific to how untyped JS is handled, but I believe that this is a result of a broader problem that can even be observed in TS files. See my comment here.
When it comes to what happens here is that this
(state, id) => state[id]function is an untyped JS function. For such wegetMinArgumentCountreturns0(unlessMinArgumentCountFlags.StrongArityForUntypedJSgets passed in but it isn't here when called throughinferFromSignature > applyToParameterTypes > getRestTypeAtPosition).Based on that
getRestTypeAtPositioncomputes[id?: any]and that gets collected as the first contravariant candidate forTProps. Then we infer into it from the second callback but there we get[]. Both are collected as viable inference candidates. And based on those twogetInferredType > getContravariantInference > getCommonSubtypedecides to pick[].
Bug Report
While working on upgrading Typescript to 5.1. from 4.7, I came across an issue with how generics are inferred with JS. Or, potentially not an issue, but a change in how it works that breaks existing code.
Importantly, this issue happens when you have a TS file importing a JS function importing a TS function. Previously, inference worked "good enough" for this scenario to work, but the change breaks it. (Unfortunately, this is a large, older codebase, so we migrate parts to TS as we can! In this case, there's a Typescript utility function, a JS redux selector using the utility, and a Typescript React component using the selector. But the only part that matters is the generic inference and having the JS file.)
🔎 Search Terms
Generics, arguments, JavaScript inference
🕗 Version & Regression Information
⏯ Playground Link
Since this requires JS code in between to TS files, I can't replicate it in the TS playground. However, you can create the three short files I mentioned below and you'll see the issue! That said, here's the working code in full TS. All that needs to change is for
getByIdto be written in JS, and the error occurs.💻 Code
Consider this simplified higher order function. Fully implemented, it would be an optimized redux-style selector that only recalculates the selector when the dependents change. Both the selector and dependents have the same signature:
Here's a simple JS file with a new selector using that function:
And here's a TS file where the selector is used:
🙁 Actual behavior
The error is that
getByIdexpects the incorrect number of arguments. It can accept 2 arguments, but the type inference restricts it to 1. The issue is that in the JS file, the two argument functions have different signatures. Typescript infers theTPropstype based on the second function -- sogetByIdis inferred as(state: any) => any.Interestingly, this is only an issue when the selector is written in JS, not TS. When
getByIdis implemented in TS 5.1, it uses the first function to infer the type ofgetById-- so it becomes(state: State, id: number ) => string.In other words, generic inference is inconsistent depending on if the function comes from JS or TS, despite most of the type information coming from a TS file in the first place!
I've not been able to find a workaround -- if we change to
Partial< TProps >for the signature ofgetDependents, we encounter issues in other selectors where the args are always defined (since Partial means some of those args can be undefined).🙂 Expected behavior
Before the update, Typescript seemingly inferred
TPropsbased on both functions -- sogetByIdbecomes(state: any, id?: any) => any.Now, I can understand that this situation is a bit tricky, and inferring based on both functions isn't ideal either. But I'm not sure how to express the types better here.
getDependentswill be called with all of the arguments, but it's not strictly necessary for the function body to accept all of those arguments when they might not be used. (And either way, this scenario works in TS, which indicates the basic premise is accepted by TS.)