Surprise in interaction between union types of val/var and tuples

I find this behavior surprising and wonder if it is according to spec:

$ scala-cli -S 3.nightly
Welcome to Scala 3.4.1-RC1-bin-20240207-551eae4-NIGHTLY-git-551eae4 (17.0.10, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.
                                                                                                                               
scala> var v1: 1 | 2 = 1
var v1: 1 | 2 = 1
                                                                                                                               
scala> val v2: 1 | 2 = 1
val v2: 1 | 2 = 1
                                                                                                                               
scala> val p = (42, 43)
val p: (Int, Int) = (42,43)
                                                                                                                               
scala> p(v1)
val res0: Any = 43
                                                                                                                               
scala> p(1)
val res1: Int = 43
                                                                                                                               
scala> p(v2)
val res2:
  v2.type match {
    case 0 => Int
    case scala.compiletime.ops.int.S[n1] =>
      Tuple.Elem[Int *: EmptyTuple.type, n1]
  } = 43

I would expect this:

                                                                                                                            
not-current-scala> p(v1)
val res0: Int = 43

not-current-scala> p(v2)
val res3: Int = 43

There are several things at play here: The apply method on tuples, union types and the difference in behavior of var/val although same type.

Can anyone explain what happens and tell if providing better inference is feasible/desirable/according to spec?

1 Like

That looks expected to me.

First note that apply is 0-indexed, so I assume you meant 0 | 1 rather than 1 | 2, but the behavior is the same regardless.

Let’s look at the val v2 first. You give that as an argument to p.apply, whose signature is:

  inline def apply[This >: this.type <: NonEmptyTuple](n: Int): Elem[This, n.type] =
    runtime.Tuples.apply(this, n).asInstanceOf[Elem[This, n.type]]

The result is therefore Elem[(Int, Int), v2.type]. We go look at what Elem looks like:

  type Elem[X <: Tuple, N <: Int] /* <: Any */ = X match {
    case x *: xs =>
      N match {
        case 0 => x
        case S[n1] => Elem[xs, n1]
      }
  }

Since the match type does not have an explicit bound, it default to <: Any (which I included as a comment above).

Now we try to reduce. The type (Int, Int) matches case x *: xs with x = Int and xs = Int *: EmptyTuple. But then we try to match N (which is v2.type) against case 0 =>. The underlying type of v2.type is 0 | 1. Clearly, 0 | 1 is not a subtype of 0, and it is also not disjoint from it (they have 0 as common subtype). So the match is stuck.

When the match is stuck, it doesn’t reduce, but we can keep it as is. This is why you get

scala> p(v2)
val res2:
  v2.type match {
    case 0 => Int
    case scala.compiletime.ops.int.S[n1] =>
      Tuple.Elem[Int *: EmptyTuple.type, n1]
  } = 43

Now if we look at the var, the situation is a bit different because every time you use a var, you have an unstable expression of its declared type, here 0 | 1 (as opposed to a stable reference). Concretely, that means you don’t get v1.type but directly 0 | 1.

Now to call apply, we need a stable n to refer to n.type in the type signature. Since we cannot use v1.type, the expression is elaborated with a temporary val:

{
  val n: 0 | 1 = v1
  p.apply(n)
}

Now n has exactly the same shape as v2, so we end up with the same type as before, but referencing n.type instead of v2.type. The problem is that n is local to the block. So when exiting the block, we need to use type avoidance to approximate the type in a way that no reference to n remains.

Since the scrutinee of a match type is invariant, we cannot locally widen n.type to 0 | 1 in the match type. Instead, we have to widen the entire match type to its upper bound. Recall that the upper bound of the match type was Any. This is why you get

scala> p(v1)
val res0: Any = 43

There is nothing that can be improved here. This is the expected result, directly resulting from all the specified typing rules. Type inference does not even get a say in this scenario.

4 Likes

Many thanks @sjrd for a really enlightening explanation!

The reason I had 1 | 2 was that the minimized example above came from a use case where I wanted to abstract over an underlying pair while converting from 1-based to 0-based and also tried to have a type safe parameter that only allowed a call with 1 or 2 as args, and then in the body subtract one before calling apply on the pair.

If I understand all this correctly: when you use apply on a tuple you may get this “strange” type : foo.type match { ... (I mean strange as it, from a user perspective, is an implementation detail how the stdlib uses advanced stuff (i.e. match types) to abstract over tuple arity.

There is nothing that can be improved here

Hmmm. When I look at the code, the Int type seems plausible to me as a human. So the machine should be able to treat this special case in a more precise way, even if it is not in general possible for all tuples?

Perhaps there can be a special case of tuple apply for small, fixed-size tuples that provides “better” types? (I know such “hacks” are dubious but, hey, Scala is pragmatic :wink: when it comes to being usable in practice.)

I’d assume that pairs are the absolutely most common tuples out there in the wild so at least special-casing Tuple2[A, B] (perhaps also when A =:= B so it is similar to Pair[A]), would perhaps help the user to get better types from apply?

2 Likes

Tuples have an apply method? Today I learned :sweat_smile:.

While I am not sure I agree with the general idea of using tuples like collections, @bjornregnell, you may want to try tuple.toList which unions the types together, so you would get List[Int] in your case.

Or use the same strategy as toList does to add your own accessor

extension [T <: NonEmptyTuple](t: T) 
 def access(n: Int): Tuple.Union[T] = 
   t.productElement(n).asInstanceOf[Tuple.Union[T]]

scala> val x:  0 | 1 | 2 = 1
val x: 0 | 1 | 2 = 1

scala> (1, 2, 3).access(x)
val res2: Int = 2

scala> var r:  0 | 1 | 2 = 1
val r: 0 | 1 | 2 = 1

scala> (1, 2, 3).access(r)
val res3: Int = 2
3 Likes