Skip to content

Inconsistent behavior when inferring a generic type from a function #52580

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
greim opened this issue Feb 2, 2023 · 7 comments Β· Fixed by #52609
Closed

Inconsistent behavior when inferring a generic type from a function #52580

greim opened this issue Feb 2, 2023 · 7 comments Β· Fixed by #52609
Labels
Bug A bug in TypeScript Help Wanted You can do this
Milestone

Comments

@greim
Copy link

greim commented Feb 2, 2023

Bug Report

πŸ”Ž Search Terms

Not sure what to search for, tried various combos of "infer" "generic" "function" but seems too general.

πŸ•— Version & Regression Information

Tried in 4.0.5, 4.6.3, and 4.9.4.

⏯ Playground Link

Playground link with relevant code

πŸ’» Code

// see also playground link

type Funcs<A, B extends Record<string, unknown>> = {
  [k in keyof B]: {
    fn: (a: A, b: B) => void;
    thing: B[k];
  };
}

function foo<A, B extends Record<string, unknown>>(fns: Funcs<A, B>) {}

foo({
  bar: {
    fn: (a: string) => {}, // <-- ERROR HERE
    thing: 'asd',
  },
});

foo({
  bar: {
    fn: (a: string, b) => {}, // <-- NO ERROR HERE
    thing: 'asd',
  },
});

Actual behavior

It infers generic <A> as unknown, in the former case only. It then checks assignability of unknown to a: string and raises an error.

Type '(a: string) => void' is not assignable to type '(a: unknown, b: { bar: string; }) => void'.
  Types of parameters 'a' and 'a' are incompatible.
    Type 'unknown' is not assignable to type 'string'.(2322)
input.tsx(3, 5): The expected type comes from property 'fn' which is declared here on type '{ fn: (a: unknown, b: { bar: string; }) => void; thing: string; }'

Expected behavior

I didn't expect it to infer unknown in the former case, and more generally was surprised that including/excluding b from the argument list would affect inference from a. I thought it should see string in both cases, and that all of the above would compile.

@RyanCavanaugh
Copy link
Member

Aside: The contravariant-only inference on A is generally a code smell. Is there a more complete example?

@RyanCavanaugh RyanCavanaugh added Bug A bug in TypeScript Help Wanted You can do this labels Feb 2, 2023
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Feb 2, 2023
@greim
Copy link
Author

greim commented Feb 3, 2023

@RyanCavanaugh - Thanks for the response. The real code is quite involved; I've tried to distill it down. The use case is a React H.O.C. which accepts a struct describing its remote data dependencies.

const WithMentorData = remoteDataDependencies({
  mentee: {
    decoder: personDecoder,
    src: (menteeId: string) => u`/people/${menteeId}`,
  },

  mentor: {
    decoder: personDecoder,
    src: (_menteeId: string, data) => {
      if (data.mentee.state === 'ok') {
        const mentee = data.mentee.value;
        const mentorId = mentee.mentor;
        return u`/people/${mentorId}`;
      }
    },
  },
});

For each dependency, src describes the URL to fetch, and decoder describes a type guard applied to the response. The src function accepts a second param data since sometimes we need the results of one fetch in order to do another. The resulting <WithMentorData/> can be used to handle data fetching for anything that needs mentor information via a render prop, as long as you have the mentee ID.

<WithMentorData
  arg={menteeId}
  render={data =>
    <MentorProfile
      menteeData={data.mentee}
      mentorData={data.mentor}
    />
  }
/>

My code mostly works. Property names (e.g. .mentee and .mentor) are known at compile time, and the decoder types carry through to those properties. The one issue is what I've described in my OP.

@Andarist
Copy link
Contributor

Andarist commented Feb 3, 2023

This looks quite interesting, gonna try to dive into this at some point - unless somebody beats me to it.

@greim
Copy link
Author

greim commented Feb 3, 2023

[edit] Looks like what I'm describing in this comment may be a known/separate issue.

Here's another oddity involving the same error message, so seems to be related. I've attached a screen recording because I can only trigger it by live-editing the code. Here's the sequence:

  1. Copy/paste the bar property.
  2. Using one keystroke, rename bar to baz.
  3. Both fn sub-properties now display the error.
  4. Make any change to the code.
  5. The errors disappear.

Please ignore if this is a separate issue and/or unrelated.


Screen recording

Screen.Recording.2023-02-03.at.9.36.29.AM.mov

@Andarist
Copy link
Contributor

Andarist commented Feb 3, 2023

This one is likely the same as the one reported here. I'm working on fixing that issue here. I'll try to verify later if it fixes your issue as well.

EDIT:// well, the other one is related to property excess checks - it's unlikely that your case is related to that. Still, it's likely related to some errors being incorrectly cached somewhere and we were discussing a more general approach to fixing that. I will try to create a test case reproducing your issue - it might help us to create a better fix.

@Andarist
Copy link
Contributor

Andarist commented Feb 3, 2023

I prepared a potential fix for this issue: #52597

As a workaround in the meantime you might want to prefer regular functions over arrows, it works with them: TS playground

@Andarist
Copy link
Contributor

Andarist commented Feb 7, 2023

I reproduced the issue with incorrect error messages using this test:

///<reference path="fourslash.ts"/>
// @strict: true
////
//// type Funcs<A, B extends Record<string, unknown>> = {
////   [K in keyof B]: {
////     fn: (a: A, b: B) => void;
////     thing: B[K];
////   }
//// }
////
//// function foo<A, B extends Record<string, unknown>>(fns: Funcs<A, B>) {}
////
//// foo({
////   bar: { fn: (a: string, b) => {}, thing: "asd" },
////   /*1*/
//// });

goTo.marker("1");
const markerPosition = test.markers()[0].position;
edit.paste(`bar: { fn: (a: string, b) => {}, thing: "asd" },`)
edit.replace(markerPosition + 4, 1, 'z')
verify.completions({ isNewIdentifierLocation: true });
verify.noErrors();

Now I need to dive deeper into this to understand why this is happening πŸ˜‰

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment