Unexpected behavior from Union types in Scala 3

One of the exciting features of Scala 3 is union types. This opinion is shared by many people according to a recent presentation by Martin Odersky at Scala Days.

I made a simple test of my naive expectations of how union types would would, and was disappointed that they don’t seem to do the magic I had hoped.

I prepared a Scastie with the code which I hoped would work. But alas it does not.

In the code I define 5 methods named Foo with the following signatures:

  1. (Int,Int)=>MyClass,
  2. (Int,MyClass)=>MyClass,
  3. (MyClass,Int)=>MyClass,
  4. (MyClass,MyClass)=>MyClass, and
  5. (Int|MyClass,Int|MyClass)=>MyClass.

Then I tried to evaluate List(1, MyClass(2), 3, MyClass(4),5,6).fold(MyClass(0))(MyClass.Foo). I was hoping the type of List(1, MyClass(2), 3, MyClass(4),5,6).fold(MyClass(0)) would be List[Int|MyClass] so that at least the 5th of the Foo methods would be applicable.
Instead, I got the compiler error.

Am I expecting completely the wrong behavior from union types?

None of the overloaded alternatives of method Foo in object MyClass with types
 (a: Int | MyClass, b: Int | MyClass): MyClass
 (b1: MyClass, b2: MyClass): MyClass
 (a1: Int, a2: Int): MyClass
 (b: MyClass, a: Int): MyClass
 (a: Int, b: MyClass): MyClass
match expected type (A1, A1) => A1

Even when I change the first method from (a: Int | MyClass, b: Int | MyClass)=>MyClass to (a: Int | MyClass, b: Int | MyClass)=> Int|MyClass, I still get the same error message. But in my opinion, the method (a: Int | MyClass, b: Int | MyClass)=> Int|MyClass DOES match the type (A1,A1)=>A1

I haven’t worked with dotty so far, but I guess it won’t just infer union types whenever you throw an arbitrary mix of types at it. (I probably wouldn’t want it to.) Thus…

List[Int|MyClass](1, MyClass(2), 3, MyClass(4),5,6).fold(MyClass(0))(MyClass.Foo)

hmmm, well my supposition was the opposite. That rather than assuming Any it would assume the least upper bound of the set of types. After all, that seems the point of having a type lattice.

List(42, 0.1, 'c', "foo", Some(3.14f), true)

What type would you expect/like to be inferred? I’d go for List[Any].

(But again, I don’t know dotty and I haven’t read anything about the union type design objectives. Maybe it’s just my old school Scala < 3 mindset…)

Well this is the question. But judging from how union types work in Common Lisp, I’d expect it to be List[Int|Float|Char|String|Option[Float]|Boolean], because that is the least upper bound of those types in the type lattice. I’m not sure why you’d want something else?

But you are absolutely right, we’d need to know what Dotty is specified to do in this case.

1 Like

Because usually when I write such an expression, it’s a List[Any] to me, anyway. :slight_smile: But of course I could explicitly declare this, as well.

The compiler will assign a union type to an expression only if such a type is explicitly given.
https://dotty.epfl.ch/docs/reference/new-types/union-types.html

More in the “Type Inference” section on the Details page.

sealed trait T
class A extends T
class B extends T
class C extends T
val lst = List(new A(), new B())

Here I’d certainly prefer List[T] to be inferred rather than List[A|B]. Not sure how this could be accomplished while avoiding the List[Any] inference in the “fully mixed list” example.

You could special case Any

Im not sure thats a good idea, but it is a possibility.

Where would you draw the line? AnyRef, java.lang.Object,…?

One could indeed argue that inferring the union type as the least upper bound is the “correct” thing to do. But that would almost always infer types that never ever occurred to me when thinking about the problem domain and that I’ve never written down, neither as a declaration nor a reference - at all levels, be it Any or T. So naively, without actually having worked with dotty so far, the status quo seems quite reasonable to me.

If you really want to dig into it, you should talk with the Dotty folks in more detail – this question has been discussed a good deal over the past few years. (The Dotty Gitter channel is probably a good place to ask.) IIRC, inferred union types tend to allow a lot of just-plain-dumb-bugs to compile unintentionally, which goes against the spirit of why you want strong types in the first place, which is sort of at the heart of Scala. It sounds nice, but hurts the overall programming experience.

So things seem to have settled down to allowing reasonably powerful explicit union types, but you have to say that you want them. That allows you to do a bunch of things that can’t be easily managed in Scala 2, while not opening the door to lots of unintended consequences…

I could see a reasonable scheme where the compiler would prefer a union type over AnyRef and AnyVal. I could also imagine a situation where an open supertype would infer a union type and a sealed supertype would infer that type. A lowest sealed upper bound, or lsub? A slub sounds more fun.