5

I'm currently stacking Futures and Eithers using EitherT:

type ErrorOr[A] = Either[Error, A]

def getAge: Future[ErrorOr[Int]] = ???
def getDob(age: Int): ErrorOr[LocalDate] = ???

for {
  age <- EitherT(getAge)
  dob <- EitherT.fromEither[Future](getDob(age))
} yield dob

I would now like to introduce the Writer monad i.e.

type MyWriter[A] = Writer[Vector[String], ErrorOr[A]]

def getAge: Future[MyWriter[Int]] = ???
def getDob(age: Int): MyWriter[LocalDate] = ???

My question is, what is the best way to sequence the getAge and getDob calls? I know monads can be stacked i.e. Future -> Writer -> Either but can I continue to use EitherT in this scenario? if so how?

Luka Jacobowitz
  • 22,795
  • 5
  • 39
  • 57

2 Answers2

7

Yeah, you can continue using both using the WriterT monad transformers like this:

type FutureErrorOr[A] = EitherT[Future, Error, A]
type MyStack[A] = WriterT[FutureErrorOr, Vector[String], A]

If you unpack this type, it's analogous to Future[Either[Error, Writer[Vector[String], A]]

Now the tricky part is lifting your functions into this base monad, so here are some examples:

def getAge: FutureErrorOr[Int] = ???
def getDob(age: Int): ErrorOr[LocalDate] = ???

for {
  age <- WriterT.liftF(getAge)
  dob <- WriterT.liftF(EitherT.fromEither(getDob(age)))
} yield dob

To make this easier you can have a look at cats-mtl.

Luka Jacobowitz
  • 22,795
  • 5
  • 39
  • 57
7

This is a slight variation to the approach given by @luka-jacobowitz. With his approach, any logs that occurred right up until a "failure" will be lost. Given the suggested types:

type FutureErrorOr[A] = EitherT[Future, Error, A]
type MyStack[A] = WriterT[FutureErrorOr, Vector[String], A]

We find that if we expand a value of MyStack[A] with the run method of WriterT we get a value of the following type:

FutureErrorOr[(Vector[String], A)]

which is the same thing as:

EitherT[Future, Error, (Vector[String], A)]

which we can then expand further with the value method of EitherT:

Future[Either[Error, (Vector[String], A)]]

Here we can see that the only way to retrieve the tuple containing the logs of the result is if the program was "successful" (ie. right associative). If the program fails, any previous logs that were created while the program was running are inaccessible.

If we take the original example and modify it slightly to log something after each step and we assume that the second step returns a value of type Left[Error]:

val program = for {
  age <- WriterT.liftF(getAge)
  _ <- WriterT.tell(Vector("Got age!"))
  dob <- WriterT.liftF(EitherT.fromEither(getDob(age))) // getDob returns Left[Error]
  _ <- WriterT.tell(Vector("Got date of birth!"))
} yield {
  dob
}

Then when we evaluate the result, we will only get back the left case containing the error without any logs:

val expanded = program.run.value // Future(Success(Left(Error)))
val result = Await.result(expanded, Duration.apply(2, TimeUnit.SECONDS)) // Left(Error), no logs!!

In order to get the value that results from running our program AND the logs that were generated up until the point where the program failed, we can reorder the suggested monads like this:

type MyWriter[A] = WriterT[Future, Vector[String], A]
type MyStack[A] = EitherT[MyWriter, Error, A]

Now, if we expand MyStack[A] with the value method of EitherT we get a value of the following type:

WriterT[Future, Vector[String], Either[Error, A]]

which we can expand further with the run method of WriterT to give us a tuple containing the logs AND the resulting value:

Future[(Vector[String], Either[Error, A])]

With this approach, we can re-write the program like this:

val program = for {
  age <- EitherT(WriterT.liftF(getAge.value))
  _ <- EitherT.liftF(WriterT.put(())(Vector("Got age!")))
  dob <- EitherT.fromEither(getDob(age))
  _ <- EitherT.liftF(WriterT.put(())(Vector("Got date of birth!")))
} yield {
  dob
}

And when we run it, we will have access to the resulting logs even if there is a failure during the program's execution:

val expanded = program.value.run // Future(Success((Vector("Got age!), Left(Error))))
val result = Await.result(expanded, Duration.apply(2, TimeUnit.SECONDS)) // (Vector("Got age!), Left(Error))

Admittedly, this solution requires a bit more boilerplate, but we can always define some helpers to aid with this:

implicit class EitherTOps[A](eitherT: FutureErrorOr[A]) {
  def lift: EitherT[MyWriter, Error, A] = {
    EitherT[MyWriter, Error, A](WriterT.liftF[Future, Vector[String], ErrorOr[A]](eitherT.value))
  }
}

implicit class EitherOps[A](either: ErrorOr[A]) {
  def lift: EitherT[MyWriter, Error, A] = {
    EitherT.fromEither[MyWriter](either)
  }
}

def log(msg: String): EitherT[MyWriter, Error, Unit] = {
  EitherT.liftF[MyWriter, Error, Unit](WriterT.put[Future, Vector[String], Unit](())(Vector(msg)))
}

val program = for {
  age <- getAge.lift
  _ <- log("Got age!")
  dob <- getDob(age).lift
  _ <- log("Got date of birth!")
} yield {
  dob
}
Luis Medina
  • 1,112
  • 1
  • 11
  • 20