IO Monad for Rookies Part 2

2019-02-02

In the previous post we explained the IO monad (and Monad Transformers). As mentioned in the article, the final solution was not very idiomatic. This is especially true for the error handling. In this post we take the final code from that article and refactor it to a more idiomatic one.

We urge you to follow along. The initial code can be found here

IOApp

Cats-effect includes an IOApp: "a safe application type that describes a main which executes a cats.effect.IO, as an entry point to a pure FP program.". It does a few things (e.g., interruption handling), but for us the main point is that it removes the need to call unsafeRunSync. We are pushing this even further outside of our universe, similar to how the JVM calls a main method. When creating an IOApp you'll need to provide the run method:

object DemoApp extends IOApp {
  …
  override def run(args: List[String]): IO[ExitCode] = ???
  …
}    

Currently we are using the result of the unsafeRunSync and pattern match the result to print some messages. One of the things that kept you awake after the last post was the fact that these side-effects were not wrapped inside an effect. They should have been wrapped inside an IO. So we need to flatmap the result of our program, plus we will need to return an Exitcode (standard return code, so zero for success and non-zero for failure). This means we will need to do two things for each case: print the message and then return the exit code. Let's have a look at how this can be achieved:

import better.files.File
import cats.effect.{ExitCode, IO, IOApp}
import cats.implicits._

override def run(args: List[String]): IO[ExitCode] =
    program.flatMap {
      case Left(error) => IO(println(s"error: $error")) >> IO(ExitCode.Error)
      case Right(_) => IO(println("successfully written")) >> IO(ExitCode.Success)
    }

Hold on, what is >> doing there? Well, we need to perform two tasks: print and return the exit code. We could have used a for-comprehension, but the IO(ExitCode.Error)/IO(ExitCode.Success) don't need the result of the previous println (IO[Unit]). This is a nice situation where you need to sequencing behaviour of a Monad, but not the flatMap which provides the result of the previous step of the sequence. The FlatMap type class (which Monad extends), provides the >> operator for this purpose (which is really just an alias for fa.flatMap(_ => fb). However it is more expressive and better shows the intent of the code).

Cool, no more ugly calls to unsafeRunSync.

Error handling

But first a little back-ground on how IO works. The code placed inside a IO.delay (same as apply) is know as a thunk ( the past participle of "think" ;) These thunks eventually get called inside the IORunLoop. This is a stack-based evaluator of all your IO actions. For example when calling IO.delay it creates a cats.effect.IO.Delay, which is eventually placed onto the internal stack. Then during execution it pattern matches on the current IO on the stack and executes it (in this case, the thunk is applied). In addition the IORunLoop surrounds this call inside a try-catch block and pushes an RaiseError on the stack

// part of IORunLoop
case Delay(thunk) =>
  try {
    unboxed = thunk().asInstanceOf[AnyRef]
    …
  } catch { case NonFatal(e) =>
    currentIO = RaiseError(e)
  }

This allows you to handle errors (there's a MonadError[IO, Throwable] available, which allows you to use operations such as handleErrorWith, recoverWith, raiseError, …)

So what this all mean. Well if we are willing to use Throwable as your error-type, we can get rid of the Try/Either and EitherT.

Read

Let's change our read signature to def read(file: File): IO[String] and make it load the bytes and then create the string:

def read(file: File): IO[String] =
    IO.delay(file.loadBytes)
      .map ( b => new String(b) )

Write

Let's next refactor the write method:

def write(file: File, s: String): IO[Unit] = IO {
  file.writeText(s)
}

Notice how little code is left :)

Convert

We can now focus on refactoring our convert operation, but before we should place the transformation to uppercase inside its own function:

def transform(s: String) : String =
    s.toUpperCase()

Our convert method needs to perform a sequence of three steps: read, transform and write. We would like to place this inside a for-comprehension. However the read/write return effects, while transform returns a simple string. We can easily lift this into an IO using the monadic pure of IO:

def convert(in: File, out: File): IO[Unit] =
    for {
      s <- read(in)
      c <- IO.pure(transform(s))
      _ <- write(out,c)
    } yield ()

The App

Lastly we need to refactor the run method of our IOApp. There are several ways to deal with errors:

  • We could have used IO.attempt and map our Throwable to IO[Either[Error, Unit]] (this way the convert method could still return a Either
  • We could use handleErrorWith and map to another IO value (we could print the error message and next return an ExitCode.Error

We will however use redeemWith. The redeem/redeemWith methods allow use to register function which are called in the case of success or failure. The difference between them is that redeemWith returns an effectful value, and redeem just a value. We need to return an IO because we are writing to the console.

  override def run(args: List[String]): IO[ExitCode] =
    convert(File.temp / "demofile", File.temp / "result")
      .redeemWith (
        e => IO(println(s"Oops [${e.getClass}] ${e.getMessage}")) >> IO(ExitCode.Error),
        _ => IO(println("Successfully wrote the file")) >> IO(ExitCode.Success)
      )

You should be able to run your application as before.

Polymorphic code

Our implementation currently is tight to the IO data type for its effects. The IO type offers implementations for many type classes (Concurrent, Async, Sync ,…). All of these offer more capabilities. Most of these we don't need. You always need to use the least powerful abstraction. In our case that would be Sync (which offers suspending of execution).

For example we could rewrite our read method like this:

def read[F[_]](file: File)(implicit ev : Sync[F]): F[String] =
    ev.delay(file.loadBytes)
      .map ( b => new String(b) )

The read method is now polymorphic. The higher-kinded type parameter F[_] just means a type that takes another type (such as List[String], IO[String], Sync[String] etc). So as long as we provide an implicit value for Sync[F] we are good to go. There's a Sync[IO] so we will just need to call read with that in scope.

As you probably are aware of, there's some syntactic sugar for the operation above. We can use a context bound to make our code more consice:

def read[F[_] : Sync](file: File): F[String] = 
  ev.delay(file.loadBytes) // OOPS Does not compile
      .map(b => new String(b))

However where do we get our type class instance from? There's no ev evidence parameter this time. Type classes in cats have a "summon" method in the form of apply that returns the available instance in implicit scope:

def read[F[_] : Sync](file: File): F[String] =
    Sync[F].delay(file.loadBytes)
           .map(b => new String(b))

Let's quickly rewrite write as well:

def write[F[_] : Sync](file: File, s: String): F[Unit] =
    Sync[F].delay(file.writeText(s))

And our convert method:

def convert[F[_] : Sync](in: File, out: File): F[Unit] =
    for {
      s <- read(in)
      c <- Monad[F].pure(transform(s))
      _ <- write(out, c)
    } yield ()

Notice we are summoning an instance of Monad[IO] as we don't need more power than that. We do need to make our type parameter Sync as this needs to be available for the read and write methods.

The only thing left do to is choose the effect type in our DemoApp by providing the value for the type parameter.

convert[IO](File.temp / "demofile", File.temp / "result")
      .redeemWith (
        e => IO(println(s"Oops [${e.getClass}] ${e.getMessage}")) >> IO(ExitCode.Error),
        _ => IO(println("Successfully wrote the file")) >> IO(ExitCode.Success)
      )

Here we could have used any other effect type that has a Sync instance (for example Monix or ZIO)

The completed solution is available at part2-solution