You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Inconsistent inference behaviour on union types #40665
constOK=fn(someBoolean ? A : B)// inferred as "a" | "b"constNO=fn(someBoolean ? C : D)// inferred as ErrorA | ErrorBconstNO_2=fn(someBoolean ? E : F)// inferred as {a:string} | {b:string}
Actual behavior:
constOK=fn(someBoolean ? A : B)// inferred as "a" | "b"constNO=fn(someBoolean ? C : D)// doesn't compileconstNO_2=fn(someBoolean ? E : F)// doesn't compile
This is the intended behavior. There are special rules that allow unifying candidates when they share a common literal base type; I don't recall the motivation behind them, but they are there on purpose and it's intentional that these behave differently.
Would it be possible to get more details about this behaviour because it feels so inconsistent with how strict function types work and may force people into writing strange helpers functions
Ryan Cavanaugh (@RyanCavanaugh) thanks for the quick picking up of the issue, I would also be really curious to know why and most importantly if that behaviour could change in the future given with the introduction of positional variance this specific behaviour would basically follow a more general one
We have a few ad hoc rules in place, such as always unioning literal types from the same domain, but otherwise, when no inference candidate is a supertype of all other inference candidates, we simply pick the first (for some meaning of first) candidate and leave it to the user to resolve the resulting errors though casts or explicit type arguments.
Ideally we would separately track inferences for mutable vs. read-only locations and produce union types whenever it is sound. For example:
declarefunctionfoo1<T>(a: T,b: T): T;foo1('abc','def');foo1('abc',42);// Could soundly infer 'abc' | 42functionfoo2<T>(a: T[],b: T[]){a[0]=b[0];}foo2(['abc'asconst],[42asconst]);// No sound inference herefunctionfoo3<T>(a: readonlyT[],b: readonlyT[]){a[0]=b[0];// Error}foo3(['abc'asconst],[42asconst]);// Could soundly infer 'abc' | 42
Some have made the argument that an error is preferable to a union type, even when a union type would be sound, because T is intended to represent a single type. For example, the error on foo1 is better than a union type because the a and b parameters are supposed to be of the same type. I'm not sure I buy that argument when a union type could soundly be inferred.
Well ideally when we can infer something sound it seems to be a better approach to infer the best candidate instead of forcing an error that will lead to manual casting of the types to a common ancestor
I've seen the lack of union inference leading to numerous casting in userland.
Those later became wrong coercion when the code changes.
I think sound union inference may lead to a better experience overall - but may be wrong.
If you want a union type to be produced, you should be using explicit type arguments, not casting.
The problem with eagerly choosing union inference is that it's really rarely what you want, especially when the type variable doesn't appear in an output position. For examples like
functionfind<T>(haystack: T[],needle: T): number{
you can "soundly infer" string | number for a call like find([1, 2, 3], "foo") but that isn't really desired behavior in 99% of cases
Ryan Cavanaugh (@RyanCavanaugh) For that particular example I don't think you can soundly infer a union since find might mutate the array. But I get your point when the function is defined as
Michael Arnaldi (@mikearnaldi) inferring a supertype (previously { }, not a union type) was the behavior until TypeScript 1.4 and we got bug reports complaining about it practically daily because it makes it nearly impossible for a generic call to be rejected. It's generally desired to have some relation between your parameters which share a common type parameter, since otherwise you would have either not had type parameters in the first place, or had two separate type parameters.
Would some sort of info about losing details help? I mean I see how this gets problematic in case the type gets inferred to a type like {} that basically erase all but if the 2 (or N) types can be unified without losing type information like 2 interfaces with different fields or an interface with the same fields but with compatible types (like tagged literals) that should be sound (at least in readonly cases)
Soundness is a lower bound on the correctness of a call, not an upper bound. TS can and does reject certain things that are not manifestly unsound, yet still very unlikely to be what the programmer intended to do (e.g. excess properties in object literals, reads on provably-impossibly values, etc).
Ryan Cavanaugh (@RyanCavanaugh) understood, thanks for the examples, going in your direction of being explicit on the behaviour wanted by the programmer a way to get the best of both worlds might be to add some sort of variance annotations on the generics, thinking of something of the sort:
Variance annotations are a possible avenue; I would also consider a noinfer keyword to turn off inference candidate collection on a particular type parameter site to be a feature that (once broadly adopted) would be sufficient to allow union inference to be the default behavior
mmm I haven't though of that it might be the less intrusive (less explicit in most cases) solution, what if instead variance annotations could be specified both at the interface/type level like type X<A+, B-> and in function site as above in a sort of hierarchal manner, like if specified at the definition site then the default behaviour is to mix, if specified at function definition site but not at type definition then the behaviour is mix, if undefined then the behaviour is invariant.
(would love to be familiar enough with the compiler internals to give it a try :()
Activity
mikearnaldi commentedon Sep 21, 2020
Similarly for intersection types in contravariant positions:
RyanCavanaugh commentedon Sep 21, 2020
This is the intended behavior. There are special rules that allow unifying candidates when they share a common literal base type; I don't recall the motivation behind them, but they are there on purpose and it's intentional that these behave differently.
r-cyr commentedon Sep 21, 2020
Would it be possible to get more details about this behaviour because it feels so inconsistent with how strict function types work and may force people into writing strange helpers functions
RyanCavanaugh commentedon Sep 21, 2020
Anders Hejlsberg (@ahejlsberg) do you happen to recall?
mikearnaldi commentedon Sep 21, 2020
Ryan Cavanaugh (@RyanCavanaugh) thanks for the quick picking up of the issue, I would also be really curious to know why and most importantly if that behaviour could change in the future given with the introduction of positional variance this specific behaviour would basically follow a more general one
ahejlsberg commentedon Sep 21, 2020
We have a few ad hoc rules in place, such as always unioning literal types from the same domain, but otherwise, when no inference candidate is a supertype of all other inference candidates, we simply pick the first (for some meaning of first) candidate and leave it to the user to resolve the resulting errors though casts or explicit type arguments.
Ideally we would separately track inferences for mutable vs. read-only locations and produce union types whenever it is sound. For example:
Some have made the argument that an error is preferable to a union type, even when a union type would be sound, because
Tis intended to represent a single type. For example, the error onfoo1is better than a union type because theaandbparameters are supposed to be of the same type. I'm not sure I buy that argument when a union type could soundly be inferred.mikearnaldi commentedon Sep 21, 2020
Well ideally when we can infer something sound it seems to be a better approach to infer the best candidate instead of forcing an error that will lead to manual casting of the types to a common ancestor
ssledorze commentedon Sep 21, 2020
I've seen the lack of union inference leading to numerous casting in userland.
Those later became wrong coercion when the code changes.
I think sound union inference may lead to a better experience overall - but may be wrong.
RyanCavanaugh commentedon Sep 21, 2020
If you want a union type to be produced, you should be using explicit type arguments, not casting.
The problem with eagerly choosing union inference is that it's really rarely what you want, especially when the type variable doesn't appear in an output position. For examples like
you can "soundly infer"
string | numberfor a call likefind([1, 2, 3], "foo")but that isn't really desired behavior in 99% of casesahejlsberg commentedon Sep 21, 2020
Ryan Cavanaugh (@RyanCavanaugh) For that particular example I don't think you can soundly infer a union since
findmight mutate the array. But I get your point when the function is defined asmikearnaldi commentedon Sep 21, 2020
Explicit type arguments force you to specify everything so I find myself using many times an actual cast.
Can you elaborate why in 99% this isn't desired? From the top of my head I can't think of a reason
RyanCavanaugh commentedon Sep 21, 2020
Michael Arnaldi (@mikearnaldi) inferring a supertype (previously
{ }, not a union type) was the behavior until TypeScript 1.4 and we got bug reports complaining about it practically daily because it makes it nearly impossible for a generic call to be rejected. It's generally desired to have some relation between your parameters which share a common type parameter, since otherwise you would have either not had type parameters in the first place, or had two separate type parameters.mikearnaldi commentedon Sep 21, 2020
Would some sort of info about losing details help? I mean I see how this gets problematic in case the type gets inferred to a type like
{}that basically erase all but if the 2 (or N) types can be unified without losing type information like 2 interfaces with different fields or an interface with the same fields but with compatible types (like tagged literals) that should be sound (at least in readonly cases)RyanCavanaugh commentedon Sep 21, 2020
Soundness is a lower bound on the correctness of a call, not an upper bound. TS can and does reject certain things that are not manifestly unsound, yet still very unlikely to be what the programmer intended to do (e.g. excess properties in object literals, reads on provably-impossibly values, etc).
mikearnaldi commentedon Sep 21, 2020
Ryan Cavanaugh (@RyanCavanaugh) understood, thanks for the examples, going in your direction of being explicit on the behaviour wanted by the programmer a way to get the best of both worlds might be to add some sort of variance annotations on the generics, thinking of something of the sort:
where if
+is specified the types would mix with aunionand if-is specified types would mix withintersection(+/-is the notation in Scala)RyanCavanaugh commentedon Sep 21, 2020
Variance annotations are a possible avenue; I would also consider a
noinferkeyword to turn off inference candidate collection on a particular type parameter site to be a feature that (once broadly adopted) would be sufficient to allow union inference to be the default behaviormikearnaldi commentedon Sep 21, 2020
mmm I haven't though of that it might be the less intrusive (less explicit in most cases) solution, what if instead variance annotations could be specified both at the
interface/typelevel liketype X<A+, B->and in function site as above in a sort of hierarchal manner, like if specified at the definition site then the default behaviour is to mix, if specified at function definition site but not at type definition then the behaviour is mix, if undefined then the behaviour is invariant.(would love to be familiar enough with the compiler internals to give it a try :()
typescript-bot commentedon Sep 24, 2020
This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.