Skip to main content
  1. Posts/

Kleisli: Composing effectful functions

·5 mins
Table of Contents

Function Composition
#

To understand Kleisli, let’s first take a look at function composition. In Scala if we define these functions:

val getNumberFromDb: Unit => Int     = _   => 2
val processNumber:   Int  => Int     = num => num * 2
val writeNumberToDb: Int  => Boolean = num => true

We can pass these functions to each other - this is the concept of higher order functions:

val combo1: Unit => Boolean = _ =>
  writeNumberToDb(processNumber(getNumberFromDb(())))

The nesting here gets a bit unwieldy. We can solve this with function composition, using Scala’s built-in compose infix function:

val combo2: Unit => Boolean =
  writeNumberToDb compose processNumber compose getNumberFromDb

combo2(()) // true

This executes in right-to-left order. getNumberFromDb is executed, then passed to processNumber, then that result is passed to writeNumberToDb.

We can make this easier to reason about by evaluating the composed functions in left-to-right order, using Scala’s built-in andThen infix function:

val combo3: Unit => Boolean =
  getNumberFromDb andThen processNumber andThen writeNumberToDb

combo3(()) // true

This executes in left-to-right order. getNumberFromDb is executed, then passed to processNumber, then that result is passed to writeNumberToDb, the same as before, but this time we can write it in a more sensible order that’s a bit easier to read (unless you find the compose approach easy). Programmer preferences….

Monads - cats.effect.IO
#

The functions we defined above return values directly. But what if we want to add IO from cats effect? For example:

val getNumberFromDb: Unit => IO[Int]     = _   => IO.pure(2)
val processNumber:   Int  => IO[Int]     = num => IO.pure(num * 2)
val writeNumberToDb: Int  => IO[Boolean] = num => IO.pure(true)

Note that now the return values are wrapped in IO. What if we wanted to compose these new IO functions together? We can’t. Adding an IO monad to the mix makes composition impossible, because Scala doesn’t understand how to compose contextual functions.

Instead of composition, what many people do in the Scala/Typelevel world is to either use flatMap:

val comboFlatMap: Unit => IO[Boolean] = Unit =>
  getNumberFromDb(()) flatMap { number =>
    processNumber(number) flatMap { processed =>
      writeNumberToDb(processed)
    }
  }

…or use for comprehensions:

val comboForComp: Unit => IO[Boolean] = Unit =>
  for {
    number    <- getNumberFromDb(())
    processed <- processNumber(number)
    result    <- writeNumberToDb(processed)
  } yield result

Either way works. It’s worth noting that the for comprehensions approach tends to be preferred over the flatMap approach, because you avoid nesting multiple functions, which can be difficult to read. The benefit of for comprehensions is that each step can be on a separate line without nesting. It’s also worth noting that the Scala compiler actually rewrites for comprehensions in this case to flatMap internally when it compiles the code.

That being said, this is not function composition. For most Scala/Typelevel codebases, I most often see either the flatMap or the for comprehension approach (with for comprehensions being more common and preferred). However, if we want to do this with function composition, while we can’t do that directly with Scala, we can by introducing Kleisli.

Kleisli
#

The type signature for Kleisli is: Kleisli[F[_], A, B]. Kleisli acts as a wrapper around the function A => F[B]. With a Kleisli, if the F has a flatMap defined, we can use it to compose functions of the form A => F[B]. Remember that with typelevel, the F would be IO.

Practically speaking, how do we implement this? Let’s examine our IO functions again:

val getNumberFromDb: Unit => IO[Int]     = _   => IO.pure(2)
val processNumber:   Int  => IO[Int]     = num => IO.pure(num * 2)
val writeNumberToDb: Int  => IO[Boolean] = num => IO.pure(true)

I’m going to show two approaches to proper composition of these functions using Kleisli.

First Form
#

val getNumberFromDbK: Kleisli[IO, Unit, Int] =
  Kleisli(getNumberFromDb)
val processNumberK: Kleisli[IO, Int, Int] =
  Kleisli(processNumber)
val writeNumberToDbK: Kleisli[IO, Int, Boolean] =
  Kleisli(writeNumberToDb)

Note that I’ve defined three new functions, with the same name as the original functions, but appended with a K. I’ve defined these functions as the Kleisli lifted form of the original functions. Meaning I’ve set these new functions equal to the original function, after it’s been passed into the Kleisli constructor.

Note that these functions have the type Kleisli[F, A, B] (the Kleisli signature) where F is replaced with IO, A is the input type of the function, and B is the output type of the function.

Because these new functions are the Kleisli forms of the original functions, they can now be composed together, using andThen, and they will compile and run:

val comboKleisli1: Kleisli[IO, Unit, Boolean] =
  getNumberFromDbK andThen
    processNumberK andThen
    writeNumberToDbK

Second Form
#

Having to write a new Kleisli function for each of the original IO functions is a bit tedious. Instead of having to do that, cats provides a mechanism for automatically lifting (or wrapping) functions into Kleisli:

val comboKleisli2: Kleisli[IO, Unit, Boolean] =
  Kleisli(getNumberFromDb) andThen
    processNumber andThen
    writeNumberToDb

The first function in the composition (getNumberFromDb) is lifted, or wrapped, via Kleisli(getNumberFromDb)). By virtue of doing that with the first function and composing with andThen, the remaining two functions don’t require us to wrap in Kleisli(). cats provides implicits so that andThen automatically lifts the rest of the functions in the composition. This means that we don’t have to define three new functions wrapping all the original functions, as we did in the first form. The second form here is a syntactically convenient short-form of Kleisli composition.

Summary
#

Basically the point of Kleisli is to allow us to compose functions that have a context, without worrying about what the context is. And by “context” we mean the IO monad in cats effect, assuming we’re using cats.effect.IO.