Skip to main content
kotlin in depth advanced patterns for java engineers

Scope Functions, Destructuring, and Operator Overloading

11 min read Chapter 12 of 21

The Five Scope Functions — Precise Semantics

Kotlin’s five scope functions — let, run, with, apply, and also — all do the same thing at a high level: execute a block of code in the context of an object. The differences are in two dimensions: how you reference the context object, and what the function returns.

FunctionObject refReturn valueInvocation style
letitLambda resultobj.let { }
runthisLambda resultobj.run { }
withthisLambda resultwith(obj) { }
applythisContext object (obj)obj.apply { }
alsoitContext object (obj)obj.also { }

Two axes: this vs it, and “returns lambda result” vs “returns the object itself.” Every scope function decision reduces to these two questions.

let — Transform or Null-Gate

let gives you the object as it and returns whatever the lambda produces. Two primary use cases:

Null-safe scoping with ?.let:

// Instead of this
val user = findUser(id)
if (user != null) {
    sendEmail(user.email)
    logAccess(user.id)
}

// You write this
findUser(id)?.let { user ->
    sendEmail(user.email)
    logAccess(user.id)
}

The named parameter user -> matters. When the block is longer than one line, giving it an explicit name prevents confusion, especially with nested scopes.

Transforming a value inline:

val hex = 255.let { "0x${it.toString(16).uppercase()}" }  // "0xFF"

In Java, you’d need a local variable or a helper method for this kind of inline transformation.

run — Execute with this Context

run is like let but the object becomes this inside the block. You access members directly without a qualifier:

val result = connection.run {
    host = "api.example.com"       // this.host
    port = 443                      // this.port
    connect()                       // this.connect()
    executeQuery("SELECT * FROM users")  // return value of the lambda
}

?.run works for null-safe operations when you want direct member access:

// When you're calling multiple methods on the same nullable object
config?.run {
    validate()
    applyDefaults()
    toConnectionString()
}

Use run over let when the block primarily accesses the object’s members — this.property reads cleaner than it.property repeated five times.

There’s also a standalone run without a receiver — useful for limiting scope:

val dbConnection = run {
    val host = System.getenv("DB_HOST") ?: "localhost"
    val port = System.getenv("DB_PORT")?.toIntOrNull() ?: 5432
    DatabaseConnection(host, port)  // returned from run
}
// host and port are not visible here

with — Non-Extension run

with is functionally identical to run, but it’s a regular function rather than an extension function. The object is passed as the first argument:

val report = with(StringBuilder()) {
    appendLine("=== Report ===")
    appendLine("Date: ${LocalDate.now()}")
    appendLine("Items: $itemCount")
    toString()  // return value
}

Use with when the object is already non-null and you want to emphasize “here’s a block of operations on this object.” The stylistic difference from run is subtle — with reads as “with this object, do…” while run reads as “on this object, run…”.

One concrete advantage: with is clearer when the object isn’t the main subject of the surrounding code:

// with reads more naturally here
val headerText = with(response.headers) {
    "${get("Content-Type")} (${get("Content-Length")} bytes)"
}

apply — Configure and Return the Object

apply provides this context like run, but returns the context object instead of the lambda result. This makes it the go-to for object configuration:

val connection = Connection().apply {
    host = "api.example.com"
    port = 443
    timeout = Duration.ofSeconds(30)
    enableCompression = true
}
// connection is the configured Connection object

In Java, this pattern requires either a builder class or telescoping method chains:

// Java — builder pattern
Connection connection = new Connection.Builder()
    .host("api.example.com")
    .port(443)
    .timeout(Duration.ofSeconds(30))
    .enableCompression(true)
    .build();

// Or — mutable setter chains (less common, requires returning 'this')
Connection connection = new Connection()
    .setHost("api.example.com")
    .setPort(443);

Kotlin’s apply eliminates the need for builder classes entirely. Any class with mutable properties can be configured inline.

also — Side Effects, Return the Object

also provides it access and returns the object. Use it for side effects that shouldn’t change the object or the return flow:

val user = createUser(request)
    .also { log.info("Created user: ${it.id}") }
    .also { metrics.incrementCounter("users.created") }

also is valuable in chains where you want to inspect intermediate values:

val result = fetchData()
    .also { println("Fetched ${it.size} items") }
    .filter { it.isValid }
    .also { println("${it.size} valid items after filtering") }
    .map { transform(it) }

Choosing the Right Scope Function

The decision tree:

  1. Do you need the return value of the lambda, or the object itself?

    • Lambda result → let, run, with
    • Object itself → apply, also
  2. Do you want this (direct member access) or it (explicit reference)?

    • thisrun, with, apply
    • itlet, also
  3. Is the object nullable?

    • Yes → Use extension form: ?.let, ?.run, ?.apply, ?.also
    • No → Any of the five, including with

There’s a common anti-pattern worth calling out — deeply nested scope functions:

// Don't do this
user?.let { u ->
    u.address?.let { addr ->
        addr.city?.let { city ->
            println(city)
        }
    }
}

// Do this instead
val city = user?.address?.city
if (city != null) {
    println(city)
}

Scope functions reduce boilerplate when used judiciously. Nesting them creates code that’s harder to read than the if checks they replace.

Destructuring Declarations

Destructuring lets you unpack an object into multiple variables in a single statement:

val (name, age) = Pair("Alice", 30)
val (key, value) = mapEntry
val (x, y, z) = Triple(1.0, 2.0, 3.0)

How It Works: componentN() Functions

Destructuring is syntactic convention backed by component1(), component2(), etc. When you write:

val (name, age) = person

The compiler translates it to:

val name = person.component1()
val age = person.component2()

Data classes generate componentN() functions automatically for each property in declaration order:

data class Employee(val name: String, val department: String, val salary: Double)

val (name, dept, salary) = Employee("Alice", "Engineering", 120_000.0)
// name = "Alice", dept = "Engineering", salary = 120_000.0

You can skip components with _:

val (name, _, salary) = employee  // Skip department

Adding Destructuring to Your Own Classes

For non-data classes, implement componentN() as operator functions:

class HttpResponse(val code: Int, val body: String, val headers: Map<String, String>) {
    operator fun component1() = code
    operator fun component2() = body
    operator fun component3() = headers
}

val (status, body, headers) = httpClient.execute(request)

Or use extension functions to add destructuring to classes you don’t own:

operator fun LocalDateTime.component1(): LocalDate = toLocalDate()
operator fun LocalDateTime.component2(): LocalTime = toLocalTime()

val (date, time) = LocalDateTime.now()

Destructuring in Lambdas

Destructuring works directly in lambda parameters — particularly useful with maps:

val scores = mapOf("Alice" to 95, "Bob" to 87, "Charlie" to 92)

// Destructure Map.Entry
scores.forEach { (name, score) ->
    println("$name scored $score")
}

// In filter
scores.filter { (_, score) -> score >= 90 }

// In map
scores.map { (name, score) -> "$name: ${if (score >= 90) "A" else "B"}" }

Compare with Java, where Map.Entry access requires explicit method calls:

scores.entrySet().stream()
    .filter(e -> e.getValue() >= 90)
    .map(e -> e.getKey() + ": A")
    .collect(Collectors.toList());

Operator Overloading

Java has exactly zero support for operator overloading. Kotlin supports it through a convention-based system: you define functions with specific names and the operator modifier, and the compiler maps operators to those functions.

Arithmetic Operators

data class Money(val amount: BigDecimal, val currency: String) {
    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "Currency mismatch: $currency vs ${other.currency}" }
        return Money(amount + other.amount, currency)
    }

    operator fun minus(other: Money): Money {
        require(currency == other.currency) { "Currency mismatch" }
        return Money(amount - other.amount, currency)
    }

    operator fun times(factor: Int): Money =
        Money(amount * factor.toBigDecimal(), currency)

    operator fun unaryMinus(): Money =
        Money(-amount, currency)

    operator fun compareTo(other: Money): Int {
        require(currency == other.currency) { "Currency mismatch" }
        return amount.compareTo(other.amount)
    }
}

val price = Money(BigDecimal("29.99"), "USD")
val tax = Money(BigDecimal("2.40"), "USD")
val total = price + tax                     // Money(32.39, USD)
val doubled = total * 2                     // Money(64.78, USD)
val refund = -price                         // Money(-29.99, USD)
val isExpensive = total > Money(BigDecimal("50.00"), "USD")  // false

In Java, the same operations require named methods with no syntactic convenience:

Money total = price.add(tax);
Money doubled = total.multiply(2);
boolean isExpensive = total.compareTo(new Money("50.00", "USD")) > 0;

Indexed Access: get and set

The [] operator maps to get() and set():

class Matrix(private val rows: Int, private val cols: Int) {
    private val data = Array(rows) { DoubleArray(cols) }

    operator fun get(row: Int, col: Int): Double = data[row][col]
    operator fun set(row: Int, col: Int, value: Double) {
        data[row][col] = value
    }
}

val matrix = Matrix(3, 3)
matrix[0, 0] = 1.0            // Calls set(0, 0, 1.0)
matrix[1, 1] = 1.0
val diagonal = matrix[0, 0]   // Calls get(0, 0)

Multi-parameter get is a Kotlin feature with no Java equivalent — even Java’s List.get() takes a single index.

Invoke: Objects as Functions

The invoke operator lets instances behave like function calls:

class Validator<T>(private val rules: List<(T) -> Boolean>) {
    operator fun invoke(value: T): Boolean = rules.all { it(value) }
}

val isValidAge = Validator(listOf(
    { it: Int -> it >= 0 },
    { it: Int -> it <= 150 }
))

println(isValidAge(25))    // true — calls invoke(25)
println(isValidAge(-1))    // false

This pattern is powerful for callable objects that maintain configuration or state — strategies, matchers, predicates.

The in Operator: contains

class DateRange(val start: LocalDate, val end: LocalDate) {
    operator fun contains(date: LocalDate): Boolean =
        date in start..end  // Uses LocalDate's compareTo

    operator fun iterator(): Iterator<LocalDate> = object : Iterator<LocalDate> {
        var current = start
        override fun hasNext() = current <= end
        override fun next() = current.also { current = it.plusDays(1) }
    }
}

val q1 = DateRange(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31))
val today = LocalDate.of(2024, 2, 15)
println(today in q1)  // true — calls q1.contains(today)

for (day in q1) {     // Uses iterator()
    // process each day in Q1
}

Complete Operator Reference Table

ExpressionFunction nameNotes
a + ba.plus(b)
a - ba.minus(b)
a * ba.times(b)
a / ba.div(b)
a % ba.rem(b)
a..ba.rangeTo(b)
a..<ba.rangeUntil(b)Kotlin 1.8+
a in bb.contains(a)Note: operands are swapped
a !in b!b.contains(a)
a[i]a.get(i)Multi-arg: a[i, j]a.get(i,j)
a[i] = va.set(i, v)
a()a.invoke()
a(x)a.invoke(x)
a += ba.plusAssign(b)Falls back to a = a.plus(b)
a -= ba.minusAssign(b)
+aa.unaryPlus()
-aa.unaryMinus()
!aa.not()
++a / a++a.inc()Must return a new value
--a / a--a.dec()Must return a new value
a == ba?.equals(b)Null-safe, maps to equals()
a > ba.compareTo(b) > 0Returns Int like Comparable
a >= ba.compareTo(b) >= 0

When Operator Overloading Becomes an Anti-Pattern

The operator keyword exists to make domain-specific code more readable. Arithmetic on Money, Vector, Matrix, Duration — these are natural fits because the mental model matches mathematical operators.

The anti-pattern emerges when operators are used for domain operations that don’t have a mathematical analogy:

// Don't do this
operator fun User.plus(role: Role): User = this.copy(roles = roles + role)
val admin = user + Role.ADMIN  // What does "adding" a role to a user mean?

// Don't do this either
operator fun Database.invoke(query: String): ResultSet = executeQuery(query)
val results = db("SELECT * FROM users")  // Looks like a function call, hides I/O

The test: would a developer unfamiliar with your codebase correctly guess what the operator does? If not, use a named function. user.grantRole(Role.ADMIN) and db.executeQuery(sql) communicate intent without requiring readers to find the operator definition.

Kotlin’s standard library follows this principle — BigDecimal, Duration, collection index access, and range construction use operators. Business logic methods like String.replace() or List.sorted() don’t, even though they could be mapped to operators.