Skip to content

Inconsistent inference behaviour on union types #40665

Closed

Description

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

Activity

  1. mikearnaldi commented on Sep 21, 2020

    @mikearnaldi
    ContributorAuthor

    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"}
  2. added
    Working as IntendedThe behavior described is the intended behavior; this is not a bug
    and removed
    Working as IntendedThe behavior described is the intended behavior; this is not a bug
    on Sep 21, 2020
  3. RyanCavanaugh commented on Sep 21, 2020

    @RyanCavanaugh
    Member

    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.

  4. r-cyr commented on Sep 21, 2020

    @r-cyr

    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

  5. RyanCavanaugh commented on Sep 21, 2020

    @RyanCavanaugh
    Member

    Anders Hejlsberg (@ahejlsberg) do you happen to recall?

  6. mikearnaldi commented on Sep 21, 2020

    @mikearnaldi
    ContributorAuthor

    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

  7. ahejlsberg commented on Sep 21, 2020

    @ahejlsberg
    Member

    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.

  8. mikearnaldi commented on Sep 21, 2020

    @mikearnaldi
    ContributorAuthor

    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

  9. ssledorze commented on Sep 21, 2020

    @ssledorze

    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.

  10. RyanCavanaugh commented on Sep 21, 2020

    @RyanCavanaugh
    Member

    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

  11. ahejlsberg commented on Sep 21, 2020

    @ahejlsberg
    Member

    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

    function find<T>(haystack: readonly T[], needle: T): number {
  12. mikearnaldi commented on Sep 21, 2020

    @mikearnaldi
    ContributorAuthor

    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

  13. RyanCavanaugh commented on Sep 21, 2020

    @RyanCavanaugh
    Member

    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.

  14. mikearnaldi commented on Sep 21, 2020

    @mikearnaldi
    ContributorAuthor

    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)

  15. RyanCavanaugh commented on Sep 21, 2020

    @RyanCavanaugh
    Member

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

  16. mikearnaldi commented on Sep 21, 2020

    @mikearnaldi
    ContributorAuthor

    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:

    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)

  17. RyanCavanaugh commented on Sep 21, 2020

    @RyanCavanaugh
    Member

    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

  18. mikearnaldi commented on Sep 21, 2020

    @mikearnaldi
    ContributorAuthor

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

  19. typescript-bot commented on Sep 24, 2020

    @typescript-bot
    Contributor

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

  20. locked as resolved and limited conversation to collaborators on Oct 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Working as IntendedThe behavior described is the intended behavior; this is not a bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions