Typescript: Inconsistent inference behaviour on union types

Created on 21 Sep 2020  路  18Comments  路  Source: microsoft/TypeScript

The behaviour of ternary operator inference (and similarly if-else & switch-case) seems to be inconsistent between types

TypeScript Version: Tested on 3.7+ up to nightly

Search Terms: union, union inference, inconsistent inference

Code

class ErrorA extends Error {
  readonly _tag = "ErrorA"
}

class ErrorB extends Error {
  readonly _tag = "ErrorB"
}

interface X<E> {
  e: () => E
}

const A = { e: () => "a" as const }
const B = { e: () => "b" as const }

const C = { e: () => new ErrorA() }
const D = { e: () => new ErrorB() }

const E = { e: () => ({a: "0"}) }
const F = { e: () => ({b: "1"}) }

declare const someBoolean: boolean
declare function fn<E>(x: X<E>): E

Expected behavior:

const OK = fn(someBoolean ? A : B) // inferred as "a" | "b"
const NO = fn(someBoolean ? C : D) // inferred as ErrorA | ErrorB
const NO_2 = fn(someBoolean ? E : F) // inferred as {a:string} | {b:string}

Actual behavior:

const OK = fn(someBoolean ? A : B) // inferred as "a" | "b"
const NO = fn(someBoolean ? C : D) // doesn't compile
const NO_2 = fn(someBoolean ? E : F) // doesn't compile

Playground Link:
https://www.typescriptlang.org/play?#code/MYGwhgzhAECiBO8D28CC0CmAPALhgdgCYwLLzQDeAUNNPBmIUviAJ7QD6OYA5tALzQARKRSohVAL5UqoSCUQoAQplwFicReWq16jZm07c+gkVqUTpVAJb488AGZhgGaAA0APLAB8lGpgAuaAAKAEoBX1gpGWBmCBxodEEKQJDw-l8hMCFoSGhY-HjoaQKilWTUsIjhACMcvNKEq0boAGEBSkr033wMAHdNMlQqkriEgBEOlIwgqozoXoHReCURmLG4Ka7q4IowIKEABiFJcNHChIAxLZm0nYoag4BGE7OZQgw5enyNiCQAWwwSiQSBADHwQRqILBYHwVA+X1cDgArvhgDhrMxoA58F5vMEsEFPD5QkEorINgB5ADSHRxwT+gOBoPB0AA-IloEElKEKRdoAA5Sl0-AMgFA6GsjntILjXktIUcABMIrFTMlsPZmyCl15QA

Working as Intended

Most helpful comment

@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

function find<T>(haystack: readonly T[], needle: T): number {

All 18 comments

Similarly for intersection types in contravariant positions:

interface X<R> {
  r: (_: R) => void
}

const A = { r: (_: {a: "a"}) => {} }
const B = { r: (_: {b: "b"}) => {} }

declare const someBoolean: boolean
declare function fn<E>(x: X<E>): E

const NO = fn(someBoolean ? A : B) // doesn't compile, expected to be {a: "a"} & {b: "b"}

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

@ahejlsberg do you happen to recall?

@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:

declare function foo1<T>(a: T, b: T): T;

foo1('abc', 'def');
foo1('abc', 42);  // Could soundly infer 'abc' | 42

function foo2<T>(a: T[], b: T[]) {
    a[0] = b[0];
}

foo2(['abc' as const], [42 as const]);  // No sound inference here

function foo3<T>(a: readonly T[], b: readonly T[]) {
    a[0] = b[0];  // Error
}

foo3(['abc' as const], [42 as const]);  // 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

function find<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

@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

function find<T>(haystack: readonly T[], needle: T): number {

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

@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).

@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:

declare function fn<A+, B->(a: X<A, B>, X<A, B>): X<A, B>

where if + is specified the types would mix with a union and if - is specified types would mix with intersection (+/- is the notation in Scala)

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 :()

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

jbondc picture jbondc  路  3Comments

weswigham picture weswigham  路  3Comments

CyrusNajmabadi picture CyrusNajmabadi  路  3Comments

bgrieder picture bgrieder  路  3Comments

Antony-Jones picture Antony-Jones  路  3Comments