Aha yes thanks; sorry - I should have seen that if there is no type annotation on the default case then this is something else than just a simple case b: Foo => b.x
… We humans just assumes things now and then - machines are different
I started to use Union types bit more heavily, since it fits the domain I am working on (messaging systems, messages could be unrelated to each other), but quickly realised the pattern match exhaustivity limitation. I found some of these warnings could be worked on by making the body inline, if using a Union where an element is of generic type?
That’s because of the generic type, and not really related to unions.
You can’t do type tests with generic types. You need a Typeable
constraint or an inline match. In Scala 2 you needed a ClassTag
for that.
There currently is an exhaustiveness issue with Typeable
though: incorrect union type exhaustive match warning with ClassTag · Issue #11541 · lampepfl/dotty · GitHub
Exhaustivity would be neat. Looking forward to it.
I just found another situation where union types can be made more useful. Consider this inferred type in scala3 RC2 REPL:
scala> (1,2,"A").toList
val res0:
List[Int | (Int |
scala.Tuple.Fold[String *: scala.Tuple$package.EmptyTuple.type, Nothing,
[x, y] =>> x | y
]
)] = List(1, 2, A)
It would be nice it the type was inferred as List[Int | String]
.
Would this be possible? Should I make a bug issue/feature request?
Note 1: Even if there is only one type in all tuple elements the inferred type gets hairy:
scala> (1,2,3).toList
val res1:
List[Int | (Int |
scala.Tuple.Fold[Int *: scala.Tuple$package.EmptyTuple.type, Nothing,
[x, y] =>> x | y
]
)] = List(1, 2, 3)
This would be nicer if inferred as List[Int]
.
Note 2: With some annotation help the compiler does not protest over the more precise union type:
scala> val xs: List[Int|String] = (1,2,"A").toList
val xs: List[Int | String] = List(1, 2, A)
This is a known issue: Deduplicate union types · Issue #10693 · lampepfl/dotty · GitHub
Aha! Thanks for the pointer.
For completeness; I found an even less precise type, falling all the way up to Object
, and not even able to prove it with ascription. However, type param works:
scala> (1,2,"A").toArray
val res3: Array[Object] = Array(1, 2, A)
scala> (1,2,"A").toArray : Array[Int | String]
1 |(1,2,"A").toArray : Array[Int | String]
|^^^^^^^^^^^^^^^^^
|Found: Array[Object]
|Required: Array[Int | String]
scala> Array[Int | String](1,2,"A")
val res4: Array[Int | String] = Array(1, 2, A)
This is unrelated to union types, it’s just how toArray is defined on Tuple: dotty/Tuple.scala at efe3a1c6abbb4aed6b88e79b6967931ef1cc5138 · lampepfl/dotty · GitHub (providing a more precise definition that returns a Tuple.Union might be possible but it seems no one attempted that so far).
Something like supporting auto-reduction, and inference on pattern matching would also be nice. For example, typescript allows writing code as follows:
type U = "A" | "B" | "C"
function uBut(x: U): "B" | "C" {
switch (x) {
case "A":
return "B"
default:
return x
}
}
If you remove the first case, there is a compiler error. The last case is automatically inferred to be union of reduced unhandled cases.
I added more details on Scala 3 here:
If you don’t specify the function output type, I think it also gets inferred to typeof x extends "A" ? "B" : typeof x
, which is also useful
I find ternary confusing enough, and they managed to put that into type level. Match types are simpler
Sorry for reviving this old conversation. (Or should I better start a new thread?)
After reading Containers I realized that this is a another use case for smarter union type method inference that would give less boilerplate if this could work:
trait Show[A]:
extension (a: A) def show: String
class C
object C:
given Show[C] with
extension (c: C) override def show: String = s"$c: C"
class D
object D:
given Show[D] with
extension (d: D) override def show: String = s"$d: D"
val ok = List[C](C(),C()).map(_.show)
val err = List[C | D](C(), D()).map(_.show)
//Error: value show is not a member of C | D
The Container-macro-magic by Mark Hammons introduces the boilerplate of .map(_.use(_.show))
and the type Container[Show]
, but it would be less strange if the above worked. @markehammons
Extension methods are picked at compiletime, which cannot be done here. I dont think this should work out of the box. With type erasure in the JVM I dont think a generic solution would even be possible without wrapping each value.
But the compiler knows statically that C and D both have the show method, so it could work. If show is a member of both C and D then it is a member of C | D.
The compiler could de-sugar this to a match
, so no reflection needed:
scala> val desugar = List[C | D](C(), D()).map(_ match
case c: C => c.show
case d: D => d.show
)
val desugar: List[String] = List(rs$line$6$C@579325f2: C, rs$line$6$D@78828aca: D)
This used to be allowed, in early development of dotty. However, we later observed that it caused more issues than benefits, so we removed that behavior.
Union types do need to be improved to be useful though. Every time I try to use them I immediately hit a problem that’s either “by-design; we are still looking for an ever moving sweetspot so currently it’s undefined if we want this or not” or just a bug that later gets fixed, or sometimes it works and a later revision of scala changes it, breaks it, and “it’s not a bug now”.
The compiler attitude of being unpredictable (according to intuition) on when it’s going to keep the specific union or when discarding it is a major hurdle. Furthermore, safe-nulls will never work with the current design based on union types, it’s just unpredictable what the compiler will do in presence of one.
Take the following examples:
def unn[T](e: T | Null): T = e.asInstanceOf[T]
val v: String | Null = "foo"
val v2 = unn(v) // works as expected, the compiler is able to infer T to being String
//---------------------------------------
def functionUnn[S](f: (S | Null) => Unit): S => Unit = ???
val someFunction: (String | Null) => Unit = _ => ()
val f2 = functionUnn(someFunction) // doesn't work, the compiler still produces (String | Null) => Unit
I’ve no idea how to make it work for the function type. I imagine it has something to do with the contravariant position, and yet I have no clue how to make it work (note that other than this case, I understand variance pretty well I’d think).
Now this might be a bug, intentional, or neither; all according to the current “specification”. So your options are: report a bug that may or not get any traction given how undefined everything regarding untion types is, write macros or code to bypass this making definitions that need to de-nullify types worse (and you will probably need to change once compiler changes are done, breaking your binary compat), do neither and give up on null-safety for as long as it depends on union-types.
Another case:
type NotNull[S >: Null] = S match {
case t | Null => t
case _ => S
}
val s: NotNull[String | Null] = null // should fail
I don’t need an explanation of why the match type doesn’t work, I get it. I believe It’s still wrong from a developer experience perspective.
The tone of my post reflects my frustration with this particular language feature, sorry if it’s not constructive (I’m legitimately not sure).
Do you perhaps recall some of the problems? Perhaps some benefits may outweigh some of the problems or perhaps some of the problems may be mitigated by some other means?
How would this work with something like
trait C[A]
trait Show[A]
object C:
def apply[A](a: A): C[A] = ???
extension [A: Show] (a: A)
def show: String = ???
given show_int: Show[C[Int]] = ???
given show_string: Show[C[String]] = ???
val list: List[C[Int] | C[String]] = List(C("asdf"), C(123))
list.map(_.show)
Runtime types will not be enough to decide which given to use for the extension method.