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
.