question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. ItĀ collects links to all the places you might be looking at while hunting down a tough bug.

And, if youā€™re still stuck at the end, weā€™re happy to hop on a call to see how we can help out.

Improvement/Bug in contextual inference where the call-site is generic.

See original GitHub issue

Feature request / Bug report

šŸ” Search Terms

Contextual inference, generic call-site

āœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldnā€™t be a breaking change in existing TypeScript/JavaScript code
  • This wouldnā€™t change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isnā€™t a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScriptā€™s Design Goals.

ā­ Suggestion / Problem

Letā€™s first study contextual inference in different scenarios. Playground for code below. Highly recommended to view it in the playground so that you can see where are the red underlines, what is the inference and what are the completions.

type X<T = never> = { $: "foo" | T }
declare const $1: <Z>(x: Z) => { $: Z }
declare const $2: <Z>(x: NoInfer<Z>) => { $: Z }
type NoInfer<T> = [T][T extends any ? 0 : never]

let _: X;
_ = $1("")               // s1t1 - Z is inferred as "" and does not compile ........................ ok
_ = $2("")               // s1t2 - Z is inferred as "foo" and does not compile ..................... nice
_ = $1("foo")            // s1t3 - Z is inferred as "foo" and compiles ............................. ok
_ = $2("foo")            // s1t4 - Z is inferred as "foo" and compiles ............................. nice

declare const m1: (x: X) => void
m1($1(""))               // s2t1 - Z is inferred as "" and does not compile ........................ ok
m1($2(""))               // s2t2 - Z is inferred as "foo" and does not compile ..................... nice
m1($1("foo"))            // s2t3 - Z is inferred as "foo" and compiles ............................. ok
m1($2("foo"))            // s2t4 - Z is inferred as "foo" and compiles ............................. nice

declare const m2: <T>(x: InferStringLiteral<T>, _: X<NoInfer<T>>) => void
type InferStringLiteral<T> = T extends string ? T : string
m2("a", $1(""))          // s3t1 - Z is inferred as "" and does not compile ........................ ok
m2("a", $2(""))          // s3t2 - Z is inferred as "foo" | "a" and does not compile ............... nice
m2("a", $1("foo"))       // s3t3 - Z is inferred as "foo" and compiles ............................. ok
m2("a", $2("a"))         // s3t4 - Z is inferred as "foo" | "a" and compiles ....................... nice

declare const m3: (_: { x: X }) => void
m3({ x: $1("") })        // s4t1 - Z is inferred as "" and does not compile ........................ ok
m3({ x: $2("") })        // s4t2 - Z is inferred as "foo" and does not compile ..................... nice
m3({ x: $1("foo") })     // s4t3 - Z is inferred as "foo" and compiles ............................. ok
m3({ x: $2("foo") })     // s4t4 - Z is inferred as "foo" and compiles ............................. nice

declare const m4: <T extends { a?: number, x: X<keyof T> }>(_: T) => void
m4({ x: $1(""), a: 1 })  // s5t1 - Z is inferred as "" and does not compile ........................ ok 
m4({ x: $2(""), a: 1 })  // s5t2 - Z is inferred as string | number | symbol and does not compile... ugh
                         //        (quickinfo is incorrect see #44879)
m4({ x: $1("a"), a: 1 }) // s5t3 - Z is inferred as "a" and does not compile ....................... ok
m4({ x: $2("a"), a: 1 }) // s5t4 - Z is inferred as string | number | symbol and does not compile .. ughhh
                         //        (quickinfo is incorrect see #44879)

declare const m5: <T, U extends X<T>>(a: InferStringLiteral<T>, x: U) => void
m5("a", $1(""))          // s6t1 - Z is inferred as "" and does not compile ........................ ok 
m5("a", $2("foo"))       // s6t2 - Z is inferred as unknown and does not compile.................... ugh
                         //        (quickinfo is incorrect see #44879)
m5("a", $1("a"))         // s6t3 - Z is inferred as "a" and does not compile ....................... ok
m5("a", $2("a"))         // s6t4 - Z is inferred as unknown and does not compile ................... ughhh
                         //        (quickinfo is incorrect see #44879)
  1. The contextual inference is expected and consistent for s1, s2, s3 & s4. But weird yet consistent for s5 & s6.

  2. I think the following should be the expected behaviorā€¦

    s5t2 - Z is inferred as "foo" | "a" | "x" and does not compile
    s5t4 - Z is inferred as "foo" | "a" | "x" and compiles
    s6t2 - Z is inferred as "foo" | "a" and does not compile
    s6t4 - Z is inferred as "foo" | "a" and compiles
    
  3. In s5t4 & s6t4, "a" is one of the completions but they are incorrect because the language server infers the type parameter different from the compiler. This is most probably a bug which might be accommodated in #44879. So all in all the completions are useless and when the language server is fixed there would be no completions.

  4. Generally speaking, a sugar-like abstraction should not result in compromises in developer experience. If you inline $*("") as { $: "" } in s5 & s6 then the completions and the compiler would work as expected.

  5. My analysis is that when the type expected from calling $* is generic itself (ie s5 & s6) then compiler canā€™t infer the type parameter Z correctly. It resolves all other generics to unknown hence the type parameter Z is inferred as keyof unknown and unknown in s5 and s6, respectively.
    Aside: Though I would expected other generics to resolve to their constraint meaning Iā€™d have expected (like not ultimately but considering the weirdness itself) the type parameter of $2 in the following scenario as "a" | "b" instead of unknown. Playground.

    type X<T = never> = { $: T | "foo" }
    declare const m6: <T extends "a" | "b", U extends X<T>>(a: T, x: U) => void
    m6("a", $2("a")) // Z is inferred as unknown
    
    declare const $2: <Z>(x: NoInfer<Z>) => { $: Z }
    type NoInfer<T> = [T][T extends any ? 0 : never]
    
  6. #44999 is most probably the consequence of this weirdness as it fits the precondition and itā€™s marked as a bug, so imo this should be also be considered as a bug. Itā€™s not apparent because the repros are vague but hopefully the following examples would make it more clear how essential it is to get this right.

  7. Maybe $1should work same as $2

šŸ“ƒ Motivating Example

Library authors provide sugar-like abstractions all the time. Take the following as an example. Playground.

createColors({
  base: { primary: "red" },
  derived: {
    primary400: lightenWithNoInfer("primary", 0.4), // does not compile
    primary500: lightenWithoutNoInfer("primary", 0.5), // does not compile
    primary600: lightenWithoutNoInfer("primary" as const, 0.6),
    primary700: lightenWithoutNoInferWithInferStringLiteral("primary", 0.7)
  }
})

declare const createColors: <C extends string>(theme:
  { base: { [colorIdentifier in C]: Color }
  , derived: 
      { [derivedColor in string]: 
          { base: NoInfer<C> // `NoInfer` so that `C` is inferred only from `base`
          , operation: (c: Color) => Color
          }
      }
  }) => "TODO"

declare const lightenWithNoInfer:
  <Z>(base: NoInfer<Z>, weight: number) => 
    { base: Z
    , operation: (c: Color) => Color
    }

declare const lightenWithoutNoInfer:
  <Z>(base: Z, weight: number) => 
    { base: Z
    , operation: (c: Color) => Color
    }

declare const lightenWithoutNoInferWithInferStringLiteral:
  <Z>(base: InferStringLiteral<Z>, weight: number) => 
    { base: Z
    , operation: (c: Color) => Color
    }
  
type Color = string;
type NoInfer<T> = [T][T extends any ? 0 : never]
type InferStringLiteral<T> = T extends string ? T : never

None of the lighten version is good.

  1. lightenWithNoInfer has completions but are incorrect and doesnā€™t compile
  2. lightenWithoutNoInfer infers Z as string instead of "primary" hence doesnā€™t compile
  3. lightenWithoutNoInferWithInferStringLiteral compiles but has no completions

If I were to be frank, there is no rocket science going on here, lightenWithoutNoInfer (or at least lightenWithNoInfer) should ā€œjust workā€ with completions and compilation.

And the problem isnā€™t about string literals per se. Hereā€™s another real world example from xstate. Playground.

createMachine({
  schema: { event: createSchema<{ type: "FETCH" }>() },
  initial: "idle",
  states: {
    idle: {
      entry: send({ type: "FETCH" }), // does not compile
      on: {
        FETCH: "fetching"
      }
    },
    fetching: {}
  }
})

declare const createMachine: <State extends string, Event extends { type: string }>(m:
  { schema: { event: Event } // we want `Event` to be inferred only from here
  , initial: NoInfer<State>
  , states:
    { [S in State]: // we want `State` to be inferred only from here
        { entry?: 
          | { type: "xstate.send"
            , event: NoInfer<Event>
            }
      , on?:
          { [E in Event["type"]]?: NoInfer<State>
          }
      }
    }
  }) => void

declare const send:
  <E>(event: NoInfer<E>) => { type: "xstate.send", event: E }

declare const createSchema: <T>() => T
type NoInfer<T> = [T][T extends any ? 0 : never];

The problem exactly is same as above. And here you can even inline send({ type: "FETCH" }) to { type: "xstate.send", type: "FETCH" } and it compiles and even provides completions.

Aside: If we add { type: string } to the entry union send("") compiles but { type: "xstate.send", event: "" } doesnā€™t, which is kinda weird too because both are equivalent.

šŸ’» Use Cases

Any case where the type parameters of a function are to be inferred from the return type instead of parameters AND location of the function call is a generic; is a use case. I suspect this improvement/bugfix will have a huge impact especially for library authors. Probably there are some folks out there banging theirs heads to make the completions work when they should probably ā€œjust workā€ without having to do anything.

Some ā€œworkaroundsā€

For s5

declare const $:
  < R
  , Z extends R extends { $: infer X } ? X : never>
    (x: Z) => R & { $: Z }

For lighten

declare const lighten:
  < R
  , C extends R extends { base: infer X } ? X : never>
    (base: C, weight: number) => 
      & R
      & { base: C
        , operation: (c: Color) => Color
        }

For send

declare const send:
  < R
  , E extends R extends { event: infer X } ? X : never>
    (event: E) =>
      & R
      & { type: "xstate.send", event: E }

All the above provide completions and compile too. Though complex cases like s4 donā€™t work with this workaround.

Thanks for reading!

Issue Analytics

  • State:open
  • Created 2 years ago
  • Comments:15 (4 by maintainers)

github_iconTop GitHub Comments

2reactions
devanshjcommented, Jul 22, 2021

Itā€™s incomplete to error more often than you should, not unsound

Yeah correct excuse my lack of savviness when it comes to wording such things senpai, what I probably meant was ā€œmost horrible-lookingā€ or perhaps ā€œmost unfortunate-lookingā€ idk xD

1reaction
RyanCavanaughcommented, Jul 22, 2021

Hereā€™s my current understanding of the issue, summarized in a minimal-yet-not-degenerately-minimal form:

type Box<T> = { value: T }
declare function box<T>(x: T): Box<T>;

declare function eatZeroBox(x: Box<0>): void;
// Passes
eatZeroBox(box(0));

declare function eatZeroBoxG<T extends Box<0>>(x: T): void;
// Passes
eatZeroBoxG(box(0 as const));
// Errors, should pass
eatZeroBoxG(box(0));

Is that right?

Read more comments on GitHub >

github_iconTop Results From Across the Web

Devansh Jethmalani on Twitter: "Look with my `LowInfer&lt;T&gt ...
Look with my `LowInfer<T>` that I'm proposing it'd also infer zustand's state ... Improvement/Bug in contextual inference where the call-site is generic.
Read more >
The Pennsylvania State University The Graduate School ...
In this work, we designed an AMD hunting system in the context of VT to ... 3.3.3.4 Signal Steganography based Scan Location Inference...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found