Improvement/Bug in contextual inference where the call-site is generic.
See original GitHub issueFeature 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)
-
The contextual inference is expected and consistent for s1, s2, s3 & s4. But weird yet consistent for s5 & s6.
-
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
-
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. -
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. -
My analysis is that when the type expected from calling
$*
is generic itself (ie s5 & s6) then compiler canāt infer the type parameterZ
correctly. It resolves all other generics tounknown
hence the type parameterZ
is inferred askeyof unknown
andunknown
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 ofunknown
. 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]
-
#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.
-
Maybe
$1
should 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.
lightenWithNoInfer
has completions but are incorrect and doesnāt compilelightenWithoutNoInfer
infersZ
asstring
instead of"primary"
hence doesnāt compilelightenWithoutNoInferWithInferStringLiteral
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:
- Created 2 years ago
- Comments:15 (4 by maintainers)
Top GitHub Comments
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
Hereās my current understanding of the issue, summarized in a minimal-yet-not-degenerately-minimal form:
Is that right?