Search code examples
scalascala-cats

How to generically flatten a reader that returns a reader


I'm using Reader from cats, and I have a load of functions which return something of type Reader.

In some cases, I want to make a function which returns a reader and does a match statement and returns a reader. The problem I have is that Reader is only contravariant in the state, not covariant in the output, and I run into some problems with subtyping. Let me make things more concrete via an example which is basically my problem

import cats.data.Reader

object U extends App {
  trait X

  case object A extends X

  case object B extends X

  type State = Int

  type ReaderWithInt[T] = Reader[State, T]

  def readWithIntPure[T](f: State => T): ReaderWithInt[T] = Reader(f)

  def readWithInt[T](f: State => ReaderWithInt[T]): ReaderWithInt[T] =
    Reader(f).flatMap(identity)

  val readerA: ReaderWithInt[A.type] = readWithIntPure(_ => A)
  val readerB: ReaderWithInt[B.type] = readWithIntPure(_ => B)

  val r: ReaderWithInt[X] = readWithInt { x: State =>
    val f: ReaderWithInt[_ >: A.type with B.type <: X] = (if (x == 0) {
      readerA
    } else {
      readerB
    })
    f
  }

  println(r.run(0))
}

This doesn't compile, it says

type mismatch;
 found   : cats.data.Kleisli[[A]A,Int,_$1] where type _$1 >: com.mypackage.U.A.type with com.mypackage.U.B.type <: com.mypackage.U.X
 required: com.mypackage.U.ReaderWithInt[com.mypackage.U.X]
    (which expands to)  cats.data.Kleisli[cats.Id,Int,com.mypackage.U.X]
    f

I can understand that, ReaderWithInt isn't covariant in T so yes that's not a valid type. I also see that in my case since I have a Functor[ReaderWithInt] available to me, I can do

Functor[ReaderWithInt].widen(f)

On the last line of the initialiser of r, and things will compile. This isn't satisfactory though, because I don't want to clutter up my uses of readWithInt with all these widen calls. I'd be happy if I could move the widen call into readWithInt.

I thought I'd be able to fix it by making readWithInt be like so

  def readWithInt[T, SType <: T](f: State => ReaderWithInt[SType]): ReaderWithInt[T] =
    Reader((i: Int) => Functor[ReaderWithInt].widen[SType, T](f(i))).flatMap(identity)

On the basis that, _ >: A.type with B.type <: X, if we call that type U, does satisfy U <: T.

But this doesn't work,

it says

found: com.mypackage.U.State => cats.data.Kleisli[[A] A, Int, _>: com.mypackage.U.A.type with com.mypackage.U.B.type <: com.mypackage.U.X] (which expands to) Int => cats.data.Kleisli[[A] A, Int, _>: com.mypackage.U.A.type with com.mypackage.U.B.type <: com.mypackage.U.X]
required: com.mypackage.U.State => com.mypackage.U.ReaderWithInt[com.mypackage.U.X] (which expands to) Int => cats.data.Kleisli[cats.Id, Int, com.mypackage.U.X]

Which I don't really understand, it's like it hasn't worked out an appropriate type to bind to SType, and has just gone with X, but I was hoping it would somehow bind SType as this

_ >: A.type with B.type <: X

type. I can see I was a bit naive here, because if I try to explicitly tell Scala this by doing

readWithInt[X, _ >: A.type with B.type <: X]

It tells me

unbound wildcard type

which OK fair enough, I'm not allowed to use wildcard types in this position.

How can I work around this? I definitely feel like what I am trying to achieve makes sense. i.e. I want to define this readWithIntPure function and have it work nicely with things that don't quite return a ReaderWithInt[T] but instead something where it returns a ReaderWithInt[A1] | ... | ReaderWithInt[An] and every Ai is a subtype of T.

Edit - I know I could write something like

  implicit def widen[U, V >: U](r: ReaderWithInt[U]): ReaderWithInt[V] = Functor[ReaderWithInt].widen(r)

But I would feel embarassed to write something like this, it doesn't seem a good idea. I would sooner add explicit widen calls to all my call sites.


Solution

  • You basically stumbled upon the typical issue with using monad transformers in Scala (here ReaderT) and that all of that is implemented as covariant.

    Had Kleisli/ReaderT been implemented as

    class Kleilsi[F[_], -A, +B](f: A => F[B]) { ... }
    

    it would work as you expect, but then F would also have to be covariant in its type

    class Kleilsi[F[+_], -A, +B](f: A => F[B]) { ... }
    

    which in turn would mean that every cats.data structure that use it would also have to be covariant... and for various reasons they are not. AFAIR it took some time for Kleisli to be even contravariant on input.

    So, sorry but no. With Cats and especially tagless final, slapping .widen and .narrow is kind of necessary. One of reasons people come up with all these value.some and none[Type] extensions to upcast values on creation.

    I imagine recommended solution for your case would be either:

      val r: ReaderWithInt[X] = readWithInt { x: State =>
        if (x == 0) readerA.widen[X]
        else readerB.widen[X]
      }
    

    or

      val readerA: ReaderWithInt[X] = readWithIntPure[X](_ => A)
      val readerB: ReaderWithInt[X] = readWithIntPure[X](_ => B)
    

    It's not that you missed something, this is one of typical complains about Cats ecosystems.