Skip to content

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

Description

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.

typeX<T=never>={$: "foo"|T}declareconst$1: <Z>(x: Z)=>{$: Z}declareconst$2: <Z>(x: NoInfer<Z>)=>{$: Z}typeNoInfer<T>=[T][Textendsany ? 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 ............................. nicedeclareconstm1: (x: X)=>voidm1($1(""))// s2t1 - Z is inferred as "" and does not compile ........................ okm1($2(""))// s2t2 - Z is inferred as "foo" and does not compile ..................... nicem1($1("foo"))// s2t3 - Z is inferred as "foo" and compiles ............................. okm1($2("foo"))// s2t4 - Z is inferred as "foo" and compiles ............................. nicedeclareconstm2: <T>(x: InferStringLiteral<T>,_: X<NoInfer<T>>)=>voidtypeInferStringLiteral<T>=Textendsstring ? T : stringm2("a",$1(""))// s3t1 - Z is inferred as "" and does not compile ........................ okm2("a",$2(""))// s3t2 - Z is inferred as "foo" | "a" and does not compile ............... nicem2("a",$1("foo"))// s3t3 - Z is inferred as "foo" and compiles ............................. okm2("a",$2("a"))// s3t4 - Z is inferred as "foo" | "a" and compiles ....................... nicedeclareconstm3: (_: {x: X})=>voidm3({x: $1("")})// s4t1 - Z is inferred as "" and does not compile ........................ okm3({x: $2("")})// s4t2 - Z is inferred as "foo" and does not compile ..................... nicem3({x: $1("foo")})// s4t3 - Z is inferred as "foo" and compiles ............................. okm3({x: $2("foo")})// s4t4 - Z is inferred as "foo" and compiles ............................. nicedeclareconstm4: <Textends{a?: number,x: X<keyofT>}>(_: T)=>voidm4({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 ....................... okm4({x: $2("a"),a: 1})// s5t4 - Z is inferred as string | number | symbol and does not compile .. ughhh// (quickinfo is incorrect see #44879)declareconstm5: <T,UextendsX<T>>(a: InferStringLiteral<T>,x: U)=>voidm5("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 ....................... okm5("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.

    typeX<T=never>={$: T|"foo"}declareconstm6: <Textends"a"|"b",UextendsX<T>>(a: T,x: U)=>voidm6("a",$2("a"))// Z is inferred as unknowndeclareconst$2: <Z>(x: NoInfer<Z>)=>{$: Z}typeNoInfer<T>=[T][Textendsany ? 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 compileprimary500: lightenWithoutNoInfer("primary",0.5),// does not compileprimary600: lightenWithoutNoInfer("primary"asconst,0.6),primary700: lightenWithoutNoInferWithInferStringLiteral("primary",0.7)}})declareconstcreateColors: <Cextendsstring>(theme:
{base: {[colorIdentifierinC]: Color},derived: {[derivedColorinstring]: {base: NoInfer<C>// `NoInfer` so that `C` is inferred only from `base`,operation: (c: Color)=>Color}}})=>"TODO"declareconstlightenWithNoInfer:
<Z>(base: NoInfer<Z>,weight: number)=>{base: Z,operation: (c: Color)=>Color}declareconstlightenWithoutNoInfer:
<Z>(base: Z,weight: number)=>{base: Z,operation: (c: Color)=>Color}declareconstlightenWithoutNoInferWithInferStringLiteral:
<Z>(base: InferStringLiteral<Z>,weight: number)=>{base: Z,operation: (c: Color)=>Color}typeColor=string;typeNoInfer<T>=[T][Textendsany ? 0 : never]typeInferStringLiteral<T>=Textendsstring ? 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 compileon: {FETCH: "fetching"}},fetching: {}}})declareconstcreateMachine: <Stateextendsstring,Eventextends{type: string}>(m:
{schema: {event: Event}// we want `Event` to be inferred only from here,initial: NoInfer<State>,states:
{[SinState]: // we want `State` to be inferred only from here{entry?: |{type: "xstate.send",event: NoInfer<Event>},on?:
{[EinEvent["type"]]?: NoInfer<State>}}}})=>voiddeclareconstsend:
<E>(event: NoInfer<E>)=>{type: "xstate.send",event: E}declareconstcreateSchema: <T>()=>TtypeNoInfer<T>=[T][Textendsany ? 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

declareconst$:
<R,ZextendsRextends{$: infer X} ? X : never>(x: Z)=>R&{$: Z}

For lighten

declareconstlighten:
<R,CextendsRextends{base: infer X} ? X : never>(base: C,weight: number)=>&R&{base: C,operation: (c: Color)=>Color}

For send

declareconstsend:
<R,EextendsRextends{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!

Metadata

Metadata

Assignees

No one assigned

    Labels

    In DiscussionNot yet reached consensusSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions