Union Type counterintuitive behavior

Given a heavily abstracted method as follows:

def someMethod(): IO[Outcome[IO, Throwable, ?]] =
  val a = IO(42)
  val b = IO("hello")
  Outcome.Succeeded(a)

where IO and Outcome are types from cats-effect, and ? is a placeholder for either String or Int, we can write:

def someMethod(): IO[Outcome[IO, Throwable, ? <: Int | String]]

I’m not sure why we need the upper bound, instead of simply Int | String. If we don’t use the upper bound, we get a compilation error.

Found: cats.effect.IO[cats.effect.kernel.Outcome[cats.effect.IO, Throwable, Int]]
Required: cats.effect.IO[cats.effect.kernel.Outcome[cats.effect.IO, Throwable, Int | String]]

Probably because of this:

The compiler assigns a union type to an expression only if such a type is explicitly given.

So, IO(42) is treated as IO[Int] and not IO[Int | String] . Using the upper bound means any type that fits inside the union Int | String , including Int , String , or the union itself, so, the types check out.

Further reduction of someMethod type signature is possible using the OutcomeIO alias:,

type OutcomeIO[A] = Outcome[IO, Throwable, A]
def someMethod[T <: Int | String](): IO[OutcomeIO[T]]

But this doesn’t compile.

Found: cats.effect.IO[cats.effect.kernel.Outcome[cats.effect.IO, Throwable, Int]]
Required: cats.effect.IO[cats.effect.kernel.Outcome[cats.effect.IO, Throwable, T]]

where: T is a type in method someMethod with bounds <: Int | String

Both the compilation errors are counterintuitive, and seems like something the compiler should be able to figure out. Your thoughts?

I think it has nothing to do with union types but with variance of cats.kernel.Outcome which is invariant.

We can reproduce it as following:

trait Outcome[F[_], E, A]
trait IO[+A] 

def someMethod(): IO[Outcome[IO, Throwable, Int | String]] =
  val a: IO[Outcome[IO, Throwable, Int]] = ???
  val b: IO[Outcome[IO, Throwable, String]] = ???
  if ??? then a else b // error

Now let’s switch the Outcome to be covariant:

trait Outcome[F[_], E, +A]

and it would be enough to make the code compile.

Since Outcome variance is unlikeklly to change probably that’s a won’t fix

2 Likes

Just for completeness, since Outcome forms a MonadError, it means we can use Functor.widen to get back the missing covariance.

if flag then a.map(_.widen) else b.map(_.widen)

You can see the code running here: Scastie - An interactive playground for Scala.