Making union types even more useful

Yes, .nn should only remove the outer layer, what I meant to say and poorly articulated is that, the times of such collection coming from java is huge, and there’s no default .nn variant to cast it away, like a .nnn or something. To make things worse, while the outer array itself will be UncheckedNull, letting you do things immediately with it, the inner type wouldn’t (IIRC).

It’s still a -Y option so should be considered as experimental and some more changes are in the pipeline:

Update: The changes to nullability have now been merged.

7 Likes

While on the topic of explicit nulls…

I remember, while reading through one of the original drafts I believe, there being some discussion about flow types and how they were needed to strip away the nullability in a scenario like def len(str: String|Null) = if str == null then -1 else str.length.

I find the concept really intriguing in a less specific setting, like for union types. Interestingly, it seems the mechanism is halfway there with pattern matching: an exhaustive warning for Y is emitted if the type X|Y is only matched on the X branch. However, despite the compiler telling us that the Y branch is unchecked, it still infers the union for a “naked” match:

(xy: X|Y) match
case x: X =>
case y     => // y is of type X|Y even though the compiler complains specifically about Y if this case is removed.

While the above example not being very useful on its own, the required foundation for these “as-you-go” refinement types seems to be in place (singleton types + intersections/unions). It does also solve the problem of stripping away part of a union in an elegant manner, which I remember toying with at some point.

4 Likes

I agree in principle. Now the only hurdles are algorithmic complexity, compilation speed, and whether the community at large cares enough about union types (which might depend on the outcome of the experiment, so it’s a chicken and egg problem). Somebody could go all in with union types and flow typing to try that experiment.

3 Likes

This happens because exhaustivity checking is run at a later phase than typechecking. Trying to move some of the logic from exhaustivity checking into the typer to give more precise types to the default case branch would be an interesting thing to try but no on has attempted that experiment so far.

4 Likes

What you are asking for makes sense, and I think any user of the system would expect the behaviour and would be surprised about this error. After all x is definitely a member of A | B. I.e., no element of the type lacks the method.

I realize that from an implementation perspective it might be difficult.

To generate a set of methods or members applicable to A|B is probably hard. But that’s not what’s being asked for here. Instead, what is being asked is whether a user named method/member is applicable.

4 Likes

It could be implemented as

def f(x: A | B) = x.y

==

def f(x: A | B) = x match {
  case x_A: A => x_A.y
  case x_B: B => x_B.y
}

and the compiler knows if A and B have y

8 Likes

I really respect that the algorithmic complexity and compilation speed are of utmost importance for community acceptance. However are union types really a bit of a stab in the back. Do they give just enough abstraction to help the people developing dotty itself, but not really an abstraction level to the mortal programmer to make them elegantly usable?

I admit my perspective is naive. When Scala 3 is used in-force, we’ll see whether user’s love them or find them almost usable.

3 Likes

I have to weigh in here: I would consider the missing pattern matching exhaustivity to be a bug. As others have said, if union types are a real (new) aspect of Scala 3 language, they should be properly checked in pattern matching. (My strong opinion comes from the fact that I’m having to work around the lack of union types in my own work these days, and am eager to be able to write cleaner, more succinct code.)

6 Likes

IIUC then exhaustivity checking itself works fine. It’s only that the type of the fall-through case is not inferred based on the earlier cases. That’s not specific to union types. It’s the same for enums or custom ADTs:

scala> sealed trait Foo; case object A extends Foo; final case class B(x: Int) extends Foo
// defined trait Foo
// defined case object A
// defined case class B

scala> (A: Foo) match {
     |   case _: A.type => -1
     |   case b => b.x
     | }
3 |  case b => b.x
  |            ^^^
  |            value x is not a member of Foo
2 Likes

Interesting! Is not that a bug? I’d think that would work because of sealed.

I answered that in Making union types even more useful - #53 by smarter.

1 Like

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

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

2 Likes

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)
2 Likes

This is a known issue: Deduplicate union types · Issue #10693 · lampepfl/dotty · GitHub

2 Likes

Aha! Thanks for the pointer.