Kotlin 101: Context Receivers Quickly Explained

Riccardo Cardin

Riccardo Cardin

18 min read  • 

kotlin

Share on:

Play

Introduction

This article will explore a powerful feature of the Kotlin programming language called context receivers. If you’re a Kotlin developer looking to write cleaner and more expressive code, context receivers are a tool you’ll want in your toolbox.

In Kotlin, context receivers provide a convenient way to access functions and properties of multiple receivers within a specific scope. Whether you’re working with interfaces, classes, or type classes, context receivers allow you to streamline your code and enhance its maintainability.

We’ll dive deeply into context receivers, starting with their purpose and benefits. We’ll explore practical examples and demonstrate how context receivers can make your Kotlin code more expressive and effective. So let’s get started and unlock the full potential of context receivers in Kotlin!

Setup

Let’s go through the setup steps to harness the power of context receivers in your Kotlin project. We’ll use Gradle with the Kotlin DSL and enable the context receivers compiling option. Make sure you have Kotlin version 1.8.22 or later installed before proceeding.

We’ll use nothing more than the Kotlin standard library this time on top of Java 19. At the end of the article, we’ll provide the complete build.gradle.kts file for your reference.

If you want to try generating the project you own, just type gradle init on a command line, and answer the questions you’ll be asked.

Context receivers are still an experimental feature. Hence, they’re not enabled by default. We need to modify the Gradle configuration. Add the kotlinOptions block within the tasks.withType<KotlinCompile> block in your build.gradle.kts file:

tasks.withType<KotlinCompile>().configureEach {
    kotlinOptions {
        freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
    }
}

We’ll explore the concept of context receivers in the context of a job search domain. To make our examples more relatable, let’s consider a simplified representation of job-related data using Kotlin data classes and inline classes.

data class Job(val id: JobId, val company: Company, val role: Role, val salary: Salary)

@JvmInline
value class JobId(val value: Long)

@JvmInline
value class Company(val name: String)

@JvmInline
value class Role(val name: String)

@JvmInline
value class Salary(val value: Double)

In the above code snippet, we have a Job data class that represents a job posting. Each Job has an id, company, role, and salary. The JobId, Company, Role, and Salary are inline classes that wrap primitive types to provide data type safety and semantic meaning.

Finally, we can define a map of jobs to mimic a database of job postings:

val JOBS_DATABASE: Map<JobId, Job> = mapOf(
    JobId(1) to Job(
        JobId(1),
        Company("Apple, Inc."),
        Role("Software Engineer"),
        Salary(70_000.00),
    ),
    JobId(2) to Job(
        JobId(2),
        Company("Microsoft"),
        Role("Software Engineer"),
        Salary(80_000.00),
    ),
    JobId(3) to Job(
        JobId(3),
        Company("Google"),
        Role("Software Engineer"),
        Salary(90_000.00),
    ),
)

Now that we have our domain objects set up let’s dive into how context receivers can simplify our code and make our job search application more efficient.

Dispatchers and Receivers

We need an example to work with to better introduce the context receivers feature. Let’s consider a function that needs to print the JSON representation of a list of jobs. We’ll call this function printAsJson:

fun printAsJson(objs: List<Job>) =
    objs.joinToString(separator = ", ", prefix = "[", postfix = "]") {
        it.toJson()
    }

If we try to compile this code, we’ll get an error since there is no toJson function defined on the Job class:

Unresolved reference: toJson

Since we don’t want to pollute our domain model, we implement the toJson extension function for the Job domain object.

fun Job.toJson(): String =
    """
        {
            "id": ${id.value},
            "company": "${company.name}",
            "role": "${role.name}",
            "salary": $salary.value}
        }
    """.trimIndent()

In Kotlin, we call the Job type the receiver of the toJson function. The receiver is the object on which the extension function is invoked, which is available in the function body as this.

So far, so good. We can now compile our code and print the JSON representation of a list of jobs:

fun main() {
    JOBS_DATABASE.values.toList().let(::printAsJson)
}

Now, we want to reuse this function in other parts of our application. However, we want to extend the function beyond printing only jobs as JSON. We want to be able to print any list of objects as JSON. So we decide to make the printAsJson function generic:

fun <T> printAsJson(objs: List<T>) =
    objs.joinToString(separator = ", ", prefix = "[", postfix = "]") {
        it.toJson()
    }

However, we return to the original problem. We still don’t have a toJson function defined on the T type. Moreover, we don’t want to change the Job or any other type adding the implementation from some weird interface that adds the toJson() methods. We could not even have access to the class code to modify it.

So, we want to execute our new parametric version of the printAsJson only in a scope where we know a toJson function is defined on the T type. Let’s start building all the pieces we need to achieve this goal.

First, we need to define the safe scope. We start implementing it as an interface that defines the toJson function:

interface JsonScope<T> {
    fun T.toJson(): String
}

Here, we introduced another characteristic of extension functions. In Kotlin, we call the JsonScope<T> the dispatcher receiver of the toJson function. In this way, we limit the visibility of the toJson function, which allows us to call it only inside the scope. We say that the toJson function is a context-dependent construct.

We can access the dispatcher receiver in the function body as this. As we might guess, Kotlin represents the this reference as a union type of the dispatcher receiver and the receiver of the extension function.

interface JsonScope<T> {    // <- dispatcher receiver
    fun T.toJson(): String  // <- extension function receiver
    // 'this' type in 'toJson' function is JsonScope<T> & T
}

The JsonScope<T> is a safe place to call the printAsJson function since we know we have access to a concrete implementation of the toJson function. Then, we define the printAsJson function as an extension function on the JsonScope interface:

fun <T> JsonScope<T>.printAsJson(objs: List<T>) =
    objs.joinToString(separator = ", ", prefix = "[", postfix = "]") { it.toJson() }

The next step is to define the JsonScope implementation for the Job type. We can implement it as an anonymous object:

val jobJsonScope = object : JsonScope<Job> {
    override fun Job.toJson(): String {
        return """
            {
                "id": ${id.value},
                "company": "${company.name}",
                "role": "${role.name}",
                "salary": $salary.value}
            }
        """.trimIndent()
    }
}

The last ring of the chain is to call the printAsJson function in the safe scope of the jobJsonScope. How can we do that? We can use one of the available scope functions in Kotlin. Usually, the with function is preferred in such situations. This function takes a receiver and a lambda as arguments and executes the lambda in the receiver’s scope. In this way, we can call the printAsJson function in the safe context of the jobJsonScope:

fun main() {
    with(jobJsonScope) {
        println(printAsJson(JOBS_DATABASE.values.toList()))
    }
}

Did we already encounter this pattern? Yes, we did. The Kotlin coroutines heavily rely on the same design. All the coroutine builders, a.k.a. launch and async, are extensions of the CoroutineScope, the dispatcher receiver and the safe place to call the suspend functions.

Moreover, if you have a Scala or Haskell background, you might notice some interesting similarities with the Type Classes. In fact, the JsonScope interface is a type class, and the jobJsonScope is an instance of the JsonScope type class for the Job type. If we were in Scala, we would have called the JsonScope type class Jsonable or something like that.

The difference between Kotlin and Scala/Haskell is that we do not have any implicit and automatic mechanism to find the correct type class instance. In Scala 2, we have the implicit classes, and in Scala 3, we have given classes. In Kotlin, we still do not have any auto-magic mechanism.

Entering the Future: Context Receivers

The approach we used so far reached the goal. However, it has some limitations.

First, we add the printAsJson function as an extension to the JsonScope interface. However, the function has nothing to do with the JsonScope type. We placed it there because it was the only technical possible solution offered. It’s somewhat misleading: The printAsJson is not a method of the JsonScope type!

Second, extension functions are only available on objects, which is only sometimes what we desire. For example, we don’t want our developers to use the printAsJson in the following way:

jobJsonScope.printAsJson(JOBS_DATABASE.values.toList())

The problem is that we can’t avoid the above usage of our DSL.

Third, we are limited to having only one receiver using extension functions with scopes. For example, let’s define a Logger interface and an implementation that logs to the console:

interface Logger {
    fun info(message: String)
}

val consoleLogger = object : Logger {
    override fun info(message: String) {
        println("[INFO] $message")
    }
}

Suppose we want to add logging capability to our printAsJson function. In that case, we can’t do it because it’s defined as an extension of the JsonScope interface, and we can’t add a second receiver to the printAsJson function.

To overcome these limitations, we must introduce a new concept: context receivers. Presented as an experimental feature in Kotlin 1.6.20, their aim is to solve the above problems and to provide a more flexible maintainable code.

In detail, context receivers are a way to add a context or a scope to a function without passing this context as an argument. If we revised how we solved the problem of the printAsJson function problem, we could see that we passed the context as an argument. In fact, the receiver of an extension function is passed to the function as an argument by the JVM once the function is interpreted in bytecode.

Kotlin introduced a new keyword, context, that allows us to specify the context the function needs to execute. In our case, we can define a new version of the printAsJson function as:

context (JsonScope<T>)
fun <T> printAsJson(objs: List<T>) =
    objs.joinToString(separator = ", ", prefix = "[", postfix = "]") {
        it.toJson()
    }

The context keyword is followed by the type of the context receiver. The context receivers are available as the this reference inside the function body. Our example allows us to access the toJson extension function defined in the JsonScope interface.

How can we bring a JsonScope instance into the scope of the printAsJson function? We can use the with function as we did before (call-site):

fun main() {
    with(jobJsonScope) {
        println(printAsJson(JOBS_DATABASE.values.toList()))
    }
}

We just solved one of our problems with the previous solution. In fact, the printAsJson function is not available as a method of the JsonScope interface. We can’t call it in the following way:

// Compilation error
jobJsonScope.printAsJson(JOBS_DATABASE.values.toList())

Yuppy! We solved the first and the problems. What about having more than one context for our function? Fortunately, we can do it. In fact, the context keyword takes an array of types as arguments. For example, we can define a printAsJson function that takes a JsonScope and a Logger as context receivers and uses the methods of both:

context (JsonScope<T>, Logger)
fun <T> printAsJson(objs: List<T>): String {
    info("Serializing $objs list as JSON")
    return objs.joinToString(separator = ", ", prefix = "[", postfix = "]") {
        it.toJson()
    }
}

As we can see, we’re using the info method of the Logger interface.

Calling the new pimped version of the printAsJson function is straightforward. We can provide both contexts using the with function, as we did before:

fun main() {
    with(jobJsonScope) {
        with(consoleLogger) {
            println(printAsJson(JOBS_DATABASE.values.toList()))
        }
    }
}

Finally, we solved all the problems we found with the previous solution.

Inside the function using the context keyword, we can’t access the context directly using the this keyword. For example, the following code doesn’t compile:

context (JsonScope<T>, Logger)
fun <T> printAsJson(objs: List<T>): String {
    this.info("Serializing $objs list as JSON")
    return objs.joinToString(separator = ", ", prefix = "[", postfix = "]") {
        it.toJson()
    }
}

In fact, the compiler complains with the following error:

'this' is not defined in this context

However, we can access referencing a particular function from a context using the @ notation, as follows:

context (JsonScope<T>, Logger)
fun <T> printAsJson(objs: List<T>): String {
    this@Logger.info("Serializing $objs list as JSON")
    return objs.joinToString(separator = ", ", prefix = "[", postfix = "]") { it.toJson() }
}

In this way, we can disambiguate the context we want to use in the case of multiple contexts defining functions with colliding names.

Another exciting thing is that the context is part of the function signature. As we saw, we can have multiple functions with the same signature in different contexts. How is it possible? The answer is how the function looks once it’s expanded by the Kotlin compiler. The contexts are explicitly passed as arguments to the compiled function. For example, in the case of our last version of the printAsJson function, the Kotlin compiler generates the following signature:

public static final <T> String printAsJson(JsonScope<T> jsonScope, Logger logger, List<T> objs)

Context receivers are also available at the class level. For example, imagine we want to define a Jobs algebra, or module, that provides a set of functions to retrieve and persist the Job type. We can define it as follows:

interface Jobs {
    suspend fun findById(id: JobId): Job?
}

A possible implementation would need to produce some logging during the execution of its methods. To be sure an instance of the Logger interface is available, we can implement the Jobs interface as follows:

context (Logger)
class LiveJobs : Jobs {
    override suspend fun findById(id: JobId): Job? {
        info("Searching job with id $id")
        return JOBS_DATABASE[id]
    }
}

As we can see, we declared the Logger context at the class level. In this way, we can access the info method of the Logger interface from any LiveJobs implementation methods. To instantiate the LiveJobs class, we can use the with function as usual:

fun main() {
    with(consoleLogger) {
        val jobs = LiveJobs()
    }
}

The above code opens an interesting question: should we use context receivers to implement an idiomatic form of dependency injection?

What Are Context Receivers Suitable For?

Now that we understand the basics of context receivers, we can ask ourselves: what are they suitable for? In the previous section, we already saw how to use them to implement type classes. However, the last example we made using them at the class definition level seems to fit quite well with the concept of dependency injection.

Let’s try to understand if context receivers are suitable for dependency injection with an example. Let’s say we have a JobsController class that exposes jobs as JSON and uses a Jobs module to retrieve them. We can define it as follows:

context (Jobs, JsonScope<Job>, Logger)
class JobController {
    suspend fun findJobById(id: String): String {
        info("Searching job with id $id")
        val jobId = JobId(id.toLong())
        return findById(jobId)?.let {
            info("Job with id $id found")
            return it.toJson()
        } ?: "No job found with id $id"
    }
}

We must provide the three required contexts to use the JobController class. We can do it using the with scope function, as we previously did:

suspend fun main() {
    with(jobJsonScope) {
        with(consoleLogger) {
            with(LiveJobs()) {
                JobController().findJobById("1").also(::println)
            }
        }
    }
}

We can see that the JobController class has three context receivers: Jobs, JsonScope<Job>, and Logger. Inside the findJobById method, the contexts are accessed without specification. The info method and the findById function are called as part of the JobController class.

The above code makes it unclear which method belongs to which class. Who owns the function findById or the function info? In general, implicit function resolution is harder to read and understand and, thus, to maintain. Moreover, we can’t avoid name clashes when using multiple contexts.

We can change and make it more explicit by using the @ notation to access the context receivers. For example, we can rewrite the findJobById method as follows:

context (Jobs, JsonScope<Job>, Logger)
class JobController {
    suspend fun findJobById(id: String): String {
        this@Logger.info("Searching job with id $id")
        val jobId = JobId(id.toLong())
        return this@Jobs.findById(jobId)?.let {
            this@Logger.info("Job with id $id found")
            return it.toJson()
        } ?: "No job found with id $id"
    }
}

However, the notation is very verbose and makes the code less readable. Moreover, we must ensure that a developer uses it.

In Scala, we had a very close problem with the Tagless Final encoding pattern.

In the past, many Scala developers started to use the pattern to implement dependency injection. Using a similar approach to context receivers, Scala allows us to define type constraints in the type parameters definition. The JobController class would look like the following in Scala:

class JobController[F[_]: Monad: Jobs: JsonScope: Logger]: F[String] {
    def findJobById(id: String): F[String] = {
        Logger[F].info(s"Searching job with id $id") *>
        Jobs[F].findById(JobId(id.toLong)).flatMap {
            case Some(job) =>
                Logger[F].info(s"Job with id $id found") *>
                job.toJson.pure[F]
            case None =>
                s"No job found with id $id".pure[F]
        }
    }
}

If you need to get more familiar with monads and higher-kinded types in Scala, don’t worry. With the syntax JobController[F[_]: Monad..., we’re just saying that the JobController class performs some I/O, and we want to be able to describe such operations and chain them without effectively executing them. It’s the same reason we added the suspend keyword to the findJobById method in the Kotlin example.

The rest of the type constraints define the contexts we need to perform the I/O operations. Then, the Scala compiler tries to implicitly resolve the contexts every time it finds a Jobs[F] or a Logger[F] in the code (Kotlin still doesn’t implement the automatic resolution of implicit contexts). It’s called summoned value pattern, and it’s implemented by a code similar to the following:

object Jobs {
  def apply[F[_]](implicit jobs: Jobs[F]): Jobs[F] = jobs
}

Anyway, in Scala, the above approach is considered an anti-pattern. In fact, while the Monad[F] and JsonScope[F] are type classes and then represent classes that have a coherent behavior among their concrete implementations, the Jobs[F] and Logger[F] are not. So, we’re mixing apples with oranges.

In general, business logic algebras should always be passed explicitly.

We can make an exception for common effects that would be shared by many of the services of our application, such as the Logger context in our example. This way, we can avoid pollution of the constructor’s signatures with the Logger context everywhere.

Summing up, our JobController class should be rewritten as follows:

context (JsonScope<Job>, Logger)
class JobController(private val jobs: Jobs) {
    suspend fun findJobById(id: String): String {
        info("Searching job with id $id")
        val jobId = JobId(id.toLong())
        return jobs.findById(jobId)?.let {
            info("Job with id $id found")
            return it.toJson()
        } ?: "No job found with id $id"
    }
}

As we can see, the code it’s easier to read. The responsibilities of each method call are clear and explicit.

So, although it’s possible to implement dependency injection through context receivers, the final solution has a lot of concerns and should be avoided.

The final use case for context receivers is to help with typed errors. In fact, the newer version of the Arrow library uses context receivers to implement an intelligent mechanism to handle typed errors when using functional error handling. However, we’ll see this in the next series article, “Functional Error Handling in Kotlin”. You can find the first two parts of the series here and here.

Conclusion

It’s time we sum up what we saw. In this article, we introduced the experimental feature of context receivers in Kotlin. First, we saw the problem it addresses using the use case of type classes, and we first implemented it through extension functions and dispatcher receivers. Then, we saw how context receivers could improve the solution. Finally, we focused on the strengths and weaknesses of context receivers, and we proved that there are better solutions for dependency injection.

In the next article, we will see how context receivers can help us handle typed errors functionally and how they’ll be used in the next version of the Arrow library.

Appendix: Gradle Configuration

As promised, here is the Gradle configuration we used to compile the code in this article. Please, remember to set up your project using the gradle init command.

plugins {
    // Apply the org.jetbrains.kotlin.jvm Plugin to add support for Kotlin.
    id("org.jetbrains.kotlin.jvm") version "1.8.22"

    // Apply the application plugin to add support for building a CLI application in Java.
    application
}

repositories {
    // Use Maven Central for resolving dependencies.
    mavenCentral()
}

dependencies {
    // Use the Kotlin JUnit 5 integration.
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")

    // Use the JUnit 5 integration.
    testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.1")
}

// Apply a specific Java toolchain to ease working on different environments.
java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(19))
    }
}

application {
    // Define the main class for the application.
    mainClass.set("in.rcard.context.receivers.AppKt")
}

tasks.named<Test>("test") {
    // Use JUnit Platform for unit tests.
    useJUnitPlatform()
}
tasks.withType<KotlinCompile>().configureEach {
    kotlinOptions {
        freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
    }
}