Context is King

Adam Warski
SoftwareMill Tech Blog
12 min readApr 8, 2021

--

Context functions are one of the new contextual abstractions coming in Scala 3. The release is approaching quickly, the design is finalized, so let’s explore the feature in more detail!

If you’d prefer a live-coding video version, see the recent Scala In The City meetup on the same topic.

An audience with the King by Zofia Warska

What is a context function?

Before we dive into usage examples and consider why you would be at all interested in using context functions, let’s see what they are and how to use them.

A regular function can be written in Scala in the following way:

val f: Int => String = (x: Int) => s"Got: $x"

A context function looks similar, however, the crucial difference is that the parameters are implicit. That is, when using the function, the parameters need to be in the implicit scope, and provided earlier to the compiler e.g. using given; by default, they are not passed explicitly.

The type of a context function is written down using ?=> instead of =>, and in the implementation, we can refer to the implicit parameters that are in scope, as defined by the type. In Scala 3, this is done using summon[T], which in Scala 2 has been known as implicitly[T]. Here, in the body of the function, we can access the given Int value:

val g: Int ?=> String = s"Got: ${summon[Int]}"

Just as f has a type Function1, g is an instance of ContextFunction1:

val ff: Function1[Int, String] = f
val gg: ContextFunction1[Int, String] = g

Context functions are regular values that can be passed around as parameters or stored in collections. We can invoke a context function by explicitly providing the implicit parameters:

println(g(using 16))

Or we can provide the value in the implicit scope. The compiler will figure out the rest:

println {
given Int = 42
g
}

Sidenote: you should never use “common” types such as Int or String for given/implicit values. Instead, anything that ends up in the implicit scope should have a narrow, custom type, to avoid accidental implicit scope contamination.

Multiple parameters & their ordering

Like regular functions, context functions can have multiple parameters, both in the curried and uncurried form:

val g1: Int ?=> Boolean ?=> String = s"${summon[Int]}  
${summon[Boolean]}"
val g2: (Int, Boolean) ?=> String = s"${summon[Int]}
${summon[Boolean]}"

But that’s not all — the Scala compiler will adjust the shape of the context functions as needed. Reordering parameters, or using a curried function where an uncurried one is needed is not a problem. For example, if we have the following functions that consume context functions:

def run1(f: Int ?=> Boolean ?=> String): Unit = 
println(f(using 9)(using false))
def run2(f: Boolean ?=> Int ?=> String): Unit =
println(f(using true)(using 10))
def run3(f: (Boolean, Int) ?=> String): Unit =
println(f(using false, 11))

we can call them with both g1 and g2 directly:

run1(g1)
run2(g1)
run3(g1)

At the invocation site, the compiler will create a new context function of the desired shape, as long as the provided function has all the necessary parameters to satisfy the requirements.

Sane ExecutionContexts

Let’s start looking at some usages! If you’ve been doing any programming using Scala 2 and Akka, you’ve probably encountered the ExecutionContext. Almost any method that was dealing with Futures probably had the additional implicit ec: ExecutionContext parameter list.

For example, here’s what a simplified fragment of a business logic function that saves a new user to the database, if a user with the given email does not yet exist, might look like in Scala 2:

case class User(email: String)def newUser(u: User)(
implicit ec: ExecutionContext): Future[Boolean] = {
lookupUser(u.email).flatMap {
case Some(_) => Future.successful(false)
case None => saveUser(u).map(_ => true)
}
}
def lookupUser(email: String)(
implicit ec: ExecutionContext): Future[Option[User]] = ???
def saveUser(u: User)(
implicit ec: ExecutionContext): Future[Unit] = ???

We assume that the lookupUser and saveUser methods interact with the database in some asynchronous or synchronous way.

Note how the ExecutionContext needs to be threaded through all of the invocations. It’s not a deal-breaker, but still an annoyance and one more piece of boilerplate. It would be great if we could capture the fact that we require the ExecutionContext in some abstract way …

Turns out, with Scala 3 we can! That’s what context functions are for. Let’s define a type alias:

type Executable[T] = ExecutionContext ?=> Future[T]

Any method where the result type is an Executable[T], will require a given (implicit) execution context to obtain the result (the Future). Here’s what our code might look like after refactoring:

case class User(email: String)def newUser(u: User): Executable[Boolean] = {
lookupUser(u.email).flatMap {
case Some(_) => Future.successful(false)
case None => saveUser(u).map(_ => true)
}
}
def lookupUser(email: String): Executable[Option[User]] = ???def saveUser(u: User): Executable[Unit] = ???

The type signatures are shorter — that’s one gain. The code is otherwise unchanged — that’s another gain. For example, the lookupUser method requires an ExecutionContext. It is automatically provided by the compiler since it is in scope — as specified by the top-level context function method signature.

Executable as an abstraction

However, the purely syntactic change we’ve seen above — giving us cleaner type signatures — isn’t the only difference. Since we now have an abstraction for “a computation requiring an execution context”, we can build combinators that operate on them. For example:

// retries the given computation up to `n` times, and returns the
// successful result, if any
def retry[T](n: Int, f: Executable[T]): Executable[T]
// runs all of the given computations, with at most `n` running in
// parallel at any time
def
runParN[T](n: Int, fs: List[Executable[T]]): Executable[List[T]]

This is possible because of a seemingly innocent syntactic, but huge semantical difference. The result of a method:

def newUser(u: User)(implicit ec: ExecutionContext): Future[Boolean]

is a running computation, which will eventually return a boolean. On the other hand:

def newUser(u: User): Executable[Boolean]

returns a lazy computation, which will only be run when an ExecutionContext is provided (either through the implicit scope or explicitly). This makes it possible to implement operators as described above, which can govern when and how the computations are run.

If you’ve encountered the IO, ZIO or Task datatypes before, this might look familiar. The basic idea behind those datatypes is similar: capture asynchronous computations as lazily evaluated values, and provide a rich set of combinators, forming a concurrency toolkit. Take a look at cats-effect, Monix, or ZIO for more details!

If you need help navigating the landscape of Scala functional programming libraries, take a look at this article recently published by Krzysztof Atłasik.

But watch out for type signatures!

However, using context functions as described above (to capture lazily-evaluated computations) has one important gotcha. The behavior might change, depending on whether you provide a type signature or not. For example:

def save(u: User): Executable[Boolean] = ???given ExecutionContext = ???
val result1 = save(u)
val result2: Executable[Boolean] = save(u)

The result1 will be a Future[Boolean] — a running computation. The compiler will eagerly use the given execution context from the current scope. However, since we’ve added a type to result2, we now have a (context) function, which will only eventually produce a Future, when supplied with the execution context.

The compiler will also happily adapt a Future[Boolean] into an Executable[Boolean]:

val v: Future[Boolean] = ???
val f: Executable[Boolean] = v

even though this might not be what you expect — each time an execution context is provided to f, it will return the same (running or completed) future v. Hence it seems that the “reified” IO/Task/ZIO datatypes are still superior to Futures or Executables when describing concurrently running computations.

King by Stanisław Warski

Explicit dependencies using implicit values

Our second usage example will touch on a subject that is often disputed among programmers: dependency injection.

Expanding our previous example, let’s take a look at the following code:

case class User(email: String)

class UserModel {
def find(email: String): Option[User] = ???
def save(u: User): Unit = ???
}

class UserService(userModel: UserModel) {
def newUser(u: User): Boolean = {
userModel.find(u.email) match {
case Some(_) => false
case None =>
userModel.save(u)
true
}
}
}

class Api(userService: UserService) {
val result: Boolean = userService.newUser(User("x@y.com"))
}

The asynchronous aspect (Futures) is omitted here, but it can be added in the same fashion as before. We’ve divided the functionality among three classes:

  • the UserModel interacts with the database and exposes functionality to lookup and save a User instance
  • the UserService implements our business logic, depending on a userModel
  • the Api exposes the functionality to the outside world — here not as e.g. an HTTP endpoint, but in a much simplified form of a direct invocation.

Note that the dependency between UserService and UserModel is hidden. The client of UserService, which here is the Api class, has no idea what kind of dependencies the service has. It’s an implementation detail of that class.

One thing is missing from the implementation, which we’ll add: transaction management. Assuming that we are working with a relational database, we would want the logic that is implemented in UserService (find & conditional save) to be run in a single transaction.

There are many approaches we can take, but we’ll opt for one that is compositional, doesn’t require frameworks or bytecode manipulation, and, at the same time, is readable. The methods in UserModel will return transaction fragments, which will then later be composed inside UserService into a larger fragment.

What is a transaction fragment? This will be any computation that needs an open Connection (we’ll assume that this represents a connection to our RDBMS) and returns some value. To make working with such computations — functions from Connection to some type T — easier, we’ll use context functions:

trait Connection
type Connected[T] = Connection ?=> T
class UserModel {
def find(email: String): Connected[Option[User]] = ???
def save(u: User): Connected[Unit] = ???
}

class UserService(userModel: UserModel) {
def newUser(u: User): Connected[Boolean] = {
userModel.find(u.email) match {
case Some(_) => false
case None =>
userModel.save(u)
true
}
}
}

Note that we haven’t changed anything in the implementation — only the type signatures! When invoking e.g. userModel.find the compiler needs a given Connection — and one is available, thanks to the type of newUser.

Once again, we are dealing with lazily evaluated computations. Here, they depend on a given connection. And again, if you’ve worked with Scala 2 libraries such as Doobie or Slick, this might look familiar:

Connected[IO[T]] ~= ConnectionIO[T] // doobie
Connected[Future[T]] ~= DBIOAction[T] // slick

The idea isn’t new, but we get a new powerful tool at our disposal to model computations — context functions.

In the Api class, we need a way to eliminate the dependency of computation on the Connection. That is, we need a way to run the transaction. That can be captured by a DB class:

class DB {
def transact[T](f: Connected[T]): T = ???
}
class Api(userService: UserService, db: DB) {
val result: Boolean =
db.transact(userService.newUser(User("x@y.pl")))
}

Hidden & explicit dependencies

We’ve noted that the UserModel is a hidden dependency of UserService. With Connected, we’re using another kind: explicit dependencies (or maybe a better name would be: context dependencies). These dependencies propagate to the caller. That is, whoever uses our method is aware of the dependency and has to provide its value (which can mean propagating the dependency further up the layer).

Both types of dependencies are useful and serve different roles. Constructors are great for implementing “traditional” dependency injection, where the dependencies are hidden. Context functions, on the other hand, can be used to easily implement explicit dependencies.

Reader monad

One more foray into the land of functional programming. We’ve seen the distinction between hidden and explicit/context dependencies before. It surfaced in the comparison between the reader monad and constructors for dependency injection.

Indeed, context functions implement the reader monad at the language level. The implementation has less syntactic and runtime overhead, so it’s definitely something to consider using!

Multiple context dependencies

As we noted in the beginning, context functions can have multiple parameters, and the exact shape of the context function (curried/uncurried) and parameter ordering doesn’t matter.

Thanks to that, we can scale the explicit dependencies described before to handle more dependencies. For example, let’s say we have two dependencies, which we want to track explicitly, and which need to be propagated to the caller:

trait Connection
type Connected[T] = Connection ?=> T
trait User
type Secure[T] = User ?=> T

We can then have a computation, which requires both of them:

trait Resource

def touch(r: Resource): Connected[Secure[Unit]] = ???

touch is an operation that is a transaction fragment, and needs to be run in the context of a logged in user. We can eliminate the inner dependency by providing a given value of the User type:

def update(r: Resource): Connected[Unit] = {
given User = ???
touch(r)
}

Again, the way in which the Connected and Secure requirements are nested doesn’t matter. Hence, we can introduce and eliminate explicit dependencies without any syntactic overhead.

King by Franciszek Warski

Context dependencies and IO monads

If you are using IO/Task or ZIO today, you might be wondering — will context functions work with these datatypes?

Let’s try! We’ll use the same dependencies, whose usage we’ll want to track as before. Suppose we have a very basic IO monad implementation, and two programs: one that is a transaction fragment and another that requires the logged in user:

case class IO[T](run: () => T) {
def flatMap[U](other: T => IO[U]): IO[U] =
IO(() => other(run()).run())
}
val p1: Secure[IO[Int]] = ???
val p2: Connected[IO[String]] = ???

The natural question is — what happens if we try to compose these two computations? That is, what happens when we flatMap p1 and p2, or the other way round?

val r1: Secure[Connected[IO[String]]] = p1.flatMap(_ => p2)
val r2: Connected[Secure[IO[String]]] = p1.flatMap(_ => p2)

Everything compiles just fine: the requirements propagate to the outer level, even though they are used inside the flatMap function.

Note that the compiler won’t infer the types for r1/r2 for us, but it will guide us through the error messages, what kind of dependency is missing. For example, if we tried to type r1: Connected[IO[String]], we’d get (the Int refers to the dependency of p1):

no implicit argument of type User was found for parameter of Secure[IO[Int]]

Context dependencies and ZIO environment

One last reference to the land of functional programming in Scala. The ZIO[R, E, A] datatype describes a computation, which given environment R, produces either an error E or a value A. This sounds similar — aren’t the environment and context functions the same?

To some degree, yes. We could draw the following comparison:

ZIO[R, E, A] ~= R ?=> asynchronously Either[E, A]

Context functions share a lot of benefits brought by ZIO’s environment: composability, easy dependency elimination & introduction, low syntactic overhead. They also have some benefits over them — dependencies don’t need to be wrapped with Has[T], in order to use and represent multiple dependencies in a single type parameter.

But they also have downsides. ZIO’s environment supports effectful dependency creation, and safe releasing of dependency resources (if any). This integrates nicely with the rest of the library, which is a toolkit for working with side-effecting and concurrent computations.

It might be possible to introduce the functionalities of resource safety to a context-function-based approach through a library. An interesting area for future work!

Context functions and side-effects

We’ve mentioned before that when working with the Executable type alias, adding a type signature can change the behavior of a program. This might indeed be a deal-breaker for this abstraction. What about the second use-case that we’ve described — managing explicit dependencies?

If the considered context function is pure — that is, invoking it doesn’t have any side-effects — the exact moment when we apply the context parameter doesn’t matter. The result will always be the same (however, it might be an optimization to e.g. invoke the function once, not a couple of times).

Note that this rules out our Executable[T] type — since it is an alias for ExecutionContext ?=> Future[T], applying an execution context is a potentially side-effecting operation: a new future might be created, and computation might be started in the background.

On the other hand, Connected[IO[T]] should be a pure function: applying an open DB connection will only yield a lazily evaluated description of a computation — no side-effects should happen. Hence context functions combined with functional effects seem to be a winning combination.

Context is everywhere

Context functions are a great amendment to Scala’s type system. They make the language more regular, as now just as a regular method can be converted to a function value, the same can be done to a method with given/implicit parameters.

Not only regularity, though: context functions open the doors to new abstraction possibilities, ranging from properly representing ExecutionContext requirements, to seamlessly propagating contextual dependencies and complementing the way Dependency Injection can be implemented using the Scala language.

Plus, there are probably many more usages that will emerge once people start working with Scala 3 on a daily basis. Looking forward! :)

--

--

Software engineer, Functional Programming and Scala enthusiast, SoftwareMill co-founder