Skip to content

Unstable type inference when generic is inferred from a return type #29771

Closed

Description

TypeScript Version: 3.2.4, 3.3.1, 3.4.0-dev.20190206

Search Terms: generic return

Code

declare function useDerivedState<T extends unknown>(
  initializer: (previous: T | undefined) => T
): T

function makeFoo(previous: { foo?: string } = {}) {
  return {
    ...previous,
    foo: 'bar'
  }
}

const state = useDerivedState(previous => makeFoo(previous))
state.foo

Expected behavior:

foo exists and is accessible

Actual behavior:

Very inconsistent.

This image is probably the most egregious example (state is { foo: string }):

image

But if I then type a whitespace or something to get the compiler to update, sometimes it starts seeing state as {}:

image

There are also some cases I can't screenshot properly. For example, this is the full derivation when it is almost working correctly:

image

But if I cause some input to happen, the derivation might reset to <{}>. However, pressing command to be able to use the screenshot tool causes the tooltip to update and it goes back to the full derivation.

The only thing that is consistent is that type of the previous argument seems to always be derived as {}.

Related Issues: #29638 and probably a bunch of others that look similar but don't quite show this odd / pathological behavior.


Giving makeFoo directly as the argument to the function avoids the problem but the current state is a minimum reproducible example of a problem I found that can't just be changed to that.

Activity

  1. dragomirtitian commented on Feb 6, 2019

    @dragomirtitian
    Contributor

    A samller example of what you are trying to do would be:

    declare function useDerivedState<T>(
      initializer: (previous: T ) => T
    ): T
    
    const state = useDerivedState(previous => 0) // state is {}

    Just as an opinion, I don't think typescript can deal with this scenario well at the moment. It's probably trying to figure out the type of the callback, and to do that it needs a type for the parameter, and there is nowhere to get that from so goes with the default of {}.

  2. Jessidhia commented on Feb 6, 2019

    @Jessidhia
    Author

    Yeah, that is probably the central cause, but the odd thing is that the inference was so unstable and would change by just interacting with the IDE even without changing the source code.


    Luckily, because I only actually needed a small portion of the previous that has a very simple type to be able to calculate the next value, I managed to refactor things such that I can use ReturnType to get the fairly complex derived type of previous itself.

    useDerivedState(
      (previous: ReturnType<typeof deriveFoo>) =>
        deriveFoo(previous !== undefined ? previous.foo.bar : undefined)
    )
    
    function deriveFoo(argument?: BarType) {
      return fooFactory(argument, factoryOptions)
    }
  3. RyanCavanaugh commented on Feb 14, 2019

    @RyanCavanaugh
    Member

    Our inference system can't really deal with a t => t' kind of function to do inferences from both sides to figure out what's going on. Sometimes language service operations manage to avoid noticing, but this is really a side effect.

  4. flaviaan commented on Mar 11, 2019

    @flaviaan

    Same here, which is very sad 😞

    interface ServerConfig<T, U, V> {
      server: () => T
      startServer: (param: T) => U
      stopServer: (param: U) => V
    }
    
    function config<T, U, V>(cfg: ServerConfig<T, U, V>): ServerConfig<T, U, V> {
      return cfg
    }

    image

    Expected behavior:

    ServerConfig to be of type

    ServerConfig<{ foo:'bar' }, string, number>

    Actual behavior:

    ServerConfig<{ foo:'bar' }, {}, number>

    Playgroung link

    The return types are sometimes inferred, sometimes not, which makes it unreliable 😢

  5. MicahZoltu commented on Feb 9, 2020

    @MicahZoltu

    Oddly, when you give the compiler less information it gets the inference correct. I don't know why this is, and perhaps is related to the "unreliability" that others were talking about:

    interface Apple<T> {
      parse: (value: string) => T,
      stringify: (value: T) => string,
    };
    declare function eatApple<T>(apple: Apple<T>): void
    eatApple({
        // infers `T` as `unknown`
        parse: value => Number.parseInt(value),
        // errors because `value` is `unknown`
        stringify: value => value.toString(),
    })
    eatApple({
        // infers `T` as `number`
        parse: (value: string) => Number.parseInt(value),
        // works because `value` is `number`
        stringify: value => value.toString(),
    })
    
    interface Banana<T> {
        parse: (value: string) => T
    }
    declare function eatBanana<T>(banana: Banana<T>): void
    eatBanana({
        // `T` correctly inferred as `number`
        parse: value => Number.parseInt(value),
    })

    Of note is that an instance of Apple incorrectly inferred T as unknown when given an untyped parse function, however Banana correctly inferred T as number when given the exact same function. The only difference between the two is that Apple has additional properties.

  6. threehams commented on Feb 9, 2020

    @threehams

    That makes sense, though. Inference only breaks down when the generic is used in both a return type position and another position.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design LimitationConstraints of the existing architecture prevent this from being fixed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions