1. Overview

Scope functions are very useful, and we use them frequently in Kotlin code.

In this tutorial, we’ll explain what they are and also provide some examples of when to use each one.

2. also

First, let’s take a look at the mutation functions also and apply.

Simply put, a mutation function operates on the given object and returns it.

In the case of also, an extension method, we provide a lambda that operates on the extended object:

inline fun T.also(block: (T) -> Unit): T

It’ll return the object it was invoked on, which makes it handy when we want to generate some side logic on a call chain:

val headers = restClient
  .getResponse()
  .also { logger.info(it.toString()) }
  .getHeaders()

Note our use of it, as this will become important later on.

And we can use also to initialize objects:

val aStudent = Student().also { it.name = "John" }

Of course, since we can refer to the instance as it, then we can also rename it, often creating something more readable:

val aStudent = Student().also { newStudent -> newStudent.name = "John"}

Certainly, if the lambda contains a complex logic, being able to name the instance will help our readers.

3. apply

But, maybe we don’t want the extra verbosity of an it lambda parameter.

apply is just like also, but with an implicit this:

inline fun T.apply(block: T.() -> Unit): T

We can use apply like we did also to initialize an object. Notice that we don’t use it, though:

val aStudent = Student().apply {
    studentId = "1234567"
    name = "Mary"
    surname = "Smith"
}

Or, we can use it to easily create builder-style objects:

data class Teacher(var id: Int = 0, var name: String = "", var surname: String = "") {
    fun id(anId: Int): Teacher = apply { id = anId }
    fun name(aName: String): Teacher = apply { name = aName }
    fun surname(aSurname: String): Teacher = apply { surname = aSurname }
}

val teacher = Teacher()
  .id(1000)
  .name("Martha")
  .surname("Spector")

The key difference here is that also uses it, while apply doesn’t.

4. let

Now, let’s take a look at the transformation functions let, run, and with which are just a step more complex than mutation functions.

Simply put, a transformation function takes a source of one type and returns a target of another type.

First up, is let:

inline fun <T, R> T.let(block: (T) -> R): R

This is quite a bit like also except that our block returns R instead of Unit.

Let’s see how this makes a difference.

First, we can use let to convert from one object type to another, like taking a StringBuilder and computing its length:

val stringBuilder = StringBuilder()
val numberOfCharacters = stringBuilder.let {
    it.append("This is a transformation function.")
    it.append  
      ("It takes a StringBuilder instance and returns the number of characters in the generated String")
    it.length
}

Or second, we can call it conditionally with the Elvis operator, also giving it a default value:

val message: String? = "hello there!"
val charactersInMessage = message?.let {
    "value was not null: $it"
} ?: "value was null"

let* is different from *also in that the return type changes.

5. run

run is related to let in the same way that apply is related to also:

inline fun <T, R> T.run(block: T.() -> R): R

Notice that we return a type R like let, making this a transformation function, but we take an implicit this, like apply.

The difference, while subtle, becomes apparent with an example:

val message = StringBuilder()
val numberOfCharacters = message.run {
    append("This is a transformation function.")
    append("It takes a StringBuilder instance and returns the number of characters in the generated String")
    length
}

With let, we referred to the message instance as it, but here, the message is the implicit this inside the lambda.

And we can use the same approach as let with nullability:

val message: String? = "hello there!"
val charactersInMessage = message?.run {
    "value was not null: $this"
} ?: "value was null"

6. with

Our last transformation function is with. It’s like run in that it has an implicit this, but it’s not an extension method:

inline fun <T, R> with(receiver: T, block: T.() -> R): R

We can use with to restrict an object to a scope. Another way of seeing it is as logically grouping multiple calls to a given object:

with(bankAccount) {
    checkAuthorization(...)
    addPayee(...)
    makePayment(...)
}

7. Conclusion

In this article, we’ve explored different scope functions, categorized them and explained them in terms of their results. There’s some overlap in their usage, but with some practice and common sense, we can learn which scope function to apply and when.

All the examples can be found in the GitHub project.