Understanding Kleisli: using it to pass context info
Kleisli
is a term that appears frequently when reading about functional programming. It is an important concept but it is not trivial to grasp. Often it is easier to understand new ideas by putting them into practice, in this blog entry we will see how to use Kleisli
to pass context info between function calls. This text assumes that you have some familiarity with Scala programming and Typelevel’s Cats
and Cats Effect
projects.
(This blog entry was inspired in the explanation about Kleisli that Fabio Labella aka SystemFw gave in the #beginners
channel in Typelevel’s discord Server, starting here. I encourage you to join that Server if you are interested in functional programming in Scala.)
Introduction
An execution flow is a sequence of function calls. Typically, in many situations we will like to have some information to be shared by the execution flow and passed by from one function to the next. There are two examples of this:
Contextual information. For example, when attending a remote request we would like to pass a request id as ‘contextual information’ to the functions that will process the request. In Java a way to do so is to use ThreadLocal, a kind of store for values whose scope is the running thread, not the function being invoked. So as the thread moves on executing functions, those functions can use
ThreadLocal
to access (and update!) their shared context info. The equivalent in cats-effect is IOLocal. A simpler alternative is just to pass the context info explicitly from one function to the next but of course this forces us to add that extracontext
parameter which introduces noise to our codeConfiguration information. This can be any setting that impacts how the function works. Config info can be made available to functions in different ways: they can be in a singleton
Config
object publicly accessible, passed as a parameter of the constructor of the class containing the function invoked, or passed in the invocation to the method itself
Kleisli
can be used as an alternative mechanism to pass such information without having to trust IOLocal
/ThreadLocal
, nor to pass it, explicitly or implicitly, in our methods calls. Truth be told, maybe Kleisli
is not so useful to pass config info in Scala thanks to the OO capabilities of the language: as commented above we can just pass the config in the class constructor or put it in a singleton. But Kleisli
can still be an useful alternative to move context info around.
Passing context info with Kleisli and currying
First, let’s remember that Kleisli is basically a wrapper around a function, so if we have a function like A => F[B]
then the Kleisli instance wrapping it will have the signature Kleisli[F[_], A, B]
. Now, if we use Kleisli to pass context info it will look like Kleisli[F[_], Context, B]
. But often our functions have other parameters too! Let’s say that our function takes an A
with some Context
to then return a B
, that is (A, Context) => F[B]
. So, where is the A
in that Kleisli[F[_], Context, B]
? How can we ‘fit’ our function in the Kleisli
? Well, we just use currying, note that we can rewrite (A, Context) => F[B]
to (A) => (Context) => F[B]
, meaning that given an A
we will return a function that takes the Context
and will return the F[B]
. In Scala code this will be something like:
def myfunc(a: A)(c: Context): F[B] = ???
and now it’s easy to insert Kleisli
in our signature:
def myfunc(a: A): Kleisli[F[_], Context, B] = ???
meaning that given an A
we will return a Kleisli
that will take a Context
and will return an F[B]
. Was this too abstract? Don’t worry, we are going to see an example that shows how to apply all of this.
Example
Let’s do a small example that will help you to understand how this works. We will use a very simple flow with four functions: generate
to create a random Int
value; two functions to transform that value processEven
(only invoked if the value is even) and processOdd
(only invoked if the value is odd); finally the writeResult
will show the result in the console. All methods also use some Context
information with a traceId
that they will print as they are invoked. Finally a process
function will sequence them together.
This would be the code when passing the context explicitly:
import cats.effect.std.Random
import cats.effect.IO
import cats.syntax.all.*
import java.util.UUID
case class Context(traceId: UUID)
def generate(c: Context): IO[Int] =
IO.println(s"Trace ${c.traceId}") >> Random.scalaUtilRandom[IO] >>= (rg => rg.betweenInt(1, 101))
def processOdd(i: Int, c: Context): IO[Double] =
IO.println(s"Trace ${c.traceId}").as(i * 0.001)
def processEven(i: Int, c: Context): IO[Double] =
IO.println(s"Trace ${c.traceId}").as(i * 1000.0)
def writeResult(d: Double, c: Context): IO[Unit] =
IO.println(s"Trace ${c.traceId} final result = $d")
val program =
for {
c <- IO.randomUUID.map(Context.apply)
rand <- generate(c)
res <- if(rand % 2 == 0) processEven(rand, c) else processOdd(rand, c)
_ <- writeResult(res, c)
} yield ()
So, as you see, when implementing this flow using just IO
we have to pass the Context
instance from one function to the next. Of course we can do as much passing the context implicitly, see:
import cats.effect.std.Random
import cats.effect.IO
import cats.syntax.all.*
import java.util.UUID
case class Context(traceId: UUID)
def generate(implicit c: Context): IO[Int] =
IO.println(s"Trace ${c.traceId}") >> Random.scalaUtilRandom[IO] >>= (rg => rg.betweenInt(1, 101))
def processOdd(i: Int)(implicit c: Context): IO[Double] =
IO.println(s"Trace ${c.traceId}").as(i * 0.001)
def processEven(i: Int)(implicit c: Context): IO[Double] =
IO.println(s"Trace ${c.traceId}").as(i * 1000.0)
def writeResult(d: Double)(implicit c: Context): IO[Unit] =
IO.println(s"Trace ${c.traceId} final result = $d")
val program =
IO.randomUUID.map(Context.apply) >>= { implicit context =>
for {
rand <- generate
res <- if (rand % 2 == 0) processEven(rand) else processOdd(rand)
_ <- writeResult(res)
} yield ()
}
Ok, so we have seen how to define our functions. Now we will use Kleisli
to pass the context instead of putting it in the method signature:
import cats.data.Kleisli
import cats.effect.std.Random
import cats.effect.IO
import cats.syntax.all.*
import java.util.UUID
case class Context(traceId: UUID)
val generate: Kleisli[IO, Context, Int] = Kleisli { (c: Context) =>
IO.println(s"Trace ${c.traceId}") >> Random.scalaUtilRandom[IO] >>= (rg => rg.betweenInt(1, 101))
}
def processOdd(i: Int): Kleisli[IO, Context, Double] = Kleisli { (c: Context) =>
IO.println(s"Trace ${c.traceId}") >> (i * 0.001).pure[IO]
}
def processEven(i: Int): Kleisli[IO, Context, Double] = Kleisli { (c: Context) =>
IO.println(s"Trace ${c.traceId}") >> (i * 1000.0).pure[IO]
}
def writeResult(d: Double): Kleisli[IO, Context, Unit] = Kleisli { (c: Context) =>
IO.println(s"Trace ${c.traceId} final result = $d")
}
val programK: Kleisli[IO, Context, Unit] = // Program as a Kleisli
for {
rand <- generate
res <- if(rand % 2 == 0) processEven(rand) else processOdd(rand)
_ <- writeResult(res)
} yield ()
val program: IO[Unit] = IO.randomUUID.map(Context.apply) >>= programK.run
Here we have just used Cats’s Kleisli to define our functions as Kleisli
instances. Now, because Kleisli is wrapping an IO
, which is a monad, we can also use it as a monad and so sequence our Kleisli
instances in a for-comprehension as we do in program
. Finally, we want to execute our program, to do that we use the .run
method of Kleisli
. In our case that method takes as input a Context
instance, so we first generate it and then pass it to program.run
(see execute
).
So, that’s all!
All code in this blog post can be found in this gist. Yo can run it using scala-cli
:
$ scala-cli PassingContextUsingKleisli.scala
Or by invoking the gist itself from scala-cli
:
scala-cli https://gist.github.com/lrodero/1d83edead347c06846ecd070fb6762dc
Subscribe to my newsletter
Read articles from Luis Rodero-Merino directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Luis Rodero-Merino
Luis Rodero-Merino
Dev interested in functional programming solutions for Scala.