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.attemptand map ourThrowabletoIO[Either[Error, Unit]](this way theconvertmethod could still return aEither - We could use
handleErrorWithand map to anotherIOvalue (we could print the error message and next return anExitCode.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