Change Either subclasses type parameters?

I’m creating this thread to replace https://github.com/scala/bug/issues/10895, where @xuwei-k proposed changing Either's subclasses. As he said it would remove the need for some asInstanceOf calls such as:

   def map[B1](f: B => B1): Either[A, B1] = this match {
     case Right(b) => Right(f(b))
-    case _        => this.asInstanceOf[Either[A, B1]]
+    case l@Left(_) => l
   }

What do you think? Are there any advantages from the current definition?

8 Likes

Pro

Right case class only captures the right-side of Either[+A, +B] disjunction. So it makes sense to me that the type parameter that it holds are limited only to B. The map shows the practical problem with carrying both side’s type information needlessly.

In the example, this is typed to Either[A, B]. Even in the case for l@Left(_), the type is Either[A, B]. The right-biased map would then transform the right-side, and thus change the return type to Either[A, B1]. So l@Left(_) gets a type error saying found Either[A, B] when Either[A, B1] is expected.

scala.Nothing

We would eventually want to treat Left(...) and Right(...) as Either[+A, +B], so we need a placeholder type. Using the bottom type Nothing gives us the maximum flexibility since:

  1. Nothing is a subtype of ever other type, and
  2. Either[+A, +B] is covariant on both sides,

so a value of Either[Nothing, B] can reside on an arbitrary Either[?, B]. Woot.

We have precedence for using the bottom type this way:

case object Nil extends List[Nothing]

....

case object None extends Option[Nothing]

The “empty” values of List and Option are both parameterized with Nothing.

Con

If in the source code you have Right[Foo, Bar], this change breaks the source compatibility.
I think it’s a minor problem since you can just use Either[Nothing, Bar] or Either[Foo, Bar] as the type.

4 Likes

If we are willing to accept this suggestion, I’d like to propose an additional small enhancement.

object Left {
  /** Creates the left side of the disjoint union.
   */
  def apply[A](value: A): Either[A, Nothing] = new Left(value)
}

This explicitly defines Left.apply(...) instead of using the normal factor method case class gives us out of the box. This hopefully helps the transition since the one-parameter version of Left become a bit more hidden.

The added benefit is that either you use Left(...) or Right(...), the type becomes Either[A, B] sort of similar to Dotty’s enums. This is convenient if we want to use Either with typeclasses.

1 Like

I’d like to see Left.apply take 2 type params to avoid the need for type ascription in various circumstances. Left would still extend Either[L, Nothing].

object Left {
  /** Creates the left side of the disjoint union.
   */
  def apply[L, R](value: L): Either[L, R] = new Left(value)
}

This is how Either.left and Either.right are defined in cats: https://github.com/typelevel/cats/blob/6353e99e9d683742937689541f48e38c6d839bed/core/src/main/scala/cats/syntax/either.scala#L287-L290

4 Likes

Sounds good to me.

But it’s not consistent with Nil/:: and None/Some.

I would be OK with methods Either.left[L, R]() and Either.right[L, R] just like they are in cats. But if Left.apply is supposed to return an Either, then Nil should return a List, but that opens up another can of worms.

2 Likes

FWIW scalaz’s IList has an INil[A]() which does indeed return IList[A] (INil itself has no subclass relationship with IList).

AFAIK scalaz tries very hard to pretend Scala doesn’t have subtyping, so that’s not surprising :wink: It’s a valid design space, but one that doesn’t match the design of the standard library.

1 Like

For the next generation of collection library, which will stay with us into the Scala 3 transition, I think Nil/:: returning List and None/Some returning Option makes perfect sense. Dotty enum already uses Option as an example:

enum Option[+T] {
  case Some(x: T) extends Option[T]
  case None       extends Option[Nothing]
}

It’s a valid design space, but one that doesn’t match the design of the standard library.

Right, I’m not necessarily advocating that it be done for the standard library, but just pointing out that it is doable that way.

This would break a common use case: infallible extractors, such as:

class MyClass(val x: Int)
object MyClass {
  def unapply(mc: MyClass): Some[Int] = Some(mc.x)
}
3 Likes

Agreed – I’d personally rather see Either.left/right added (along with Option.some/none) and then leave the apply methods of subtypes as-is.

Would it matter in this case if the return type is Some[Int] or Option[Int]?

Yes, having it be Some[Int] tells the Scala compiler that the unapplication cannot fail, which allows us to retain exhaustive checking of pattern matching expressions.

2 Likes

I see. Thanks for the explanation.

Except if we adopted @xuwei-k’s proposal, the return type of val x = Left(1); x changes.

Before:

scala> val x = Left(1); x
x: scala.util.Left[Int,Nothing] = Left(1)
res0: scala.util.Left[Int,Nothing] = Left(1)

After:

scala> val x = Left(1); x
x: scala.util.Left[Int] = Left(1)
res0: scala.util.Left[Int] = Left(1)

and of course val y = Left[Int, String](1); y will no longer compile:

scala> val y = Left[Int, String](1); y
                   ^
       error: wrong number of type parameters for method apply: [A](value: A)scala.util.Left[A] in object Left

So for cross-compiling, I think your apply with two type parameter would have smoother migration.

For the same reason, I also hope the type parameter of Failure get removed, and let it extend Try[Nothing] instead.

If you want Option.Some[Int] you have to use new Option.Some(mc.x) instead.

Also, I’m really really surprised by this change in Dotty. I’m not sure I’d describe it as a “a subtle difference with respect to normal case classes”.

1 Like

Open a PR. If we can change this I think we should.

:+1:
I kept hoping for this change :slight_smile: