Sitemap
Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Kotlin lambda with receiver.

--

Clear code is the key — how to change the scope of a lambda?

Kotlin’s primary mechanism for creating Domain-Specific Languages (DSLs) is the lambda with receiver (T.() -> Unit), combined with extension functions, operator overloading, and infix functions. This enables expressive, readable, type-safe configuration and builder APIs.

First of all, let’s look at 1 neat Kotlin syntax feature, double-lambda (curried function): a method gets 2 inputs as String and returns Unit (meaning void).

val doubleGreeting: (String) -> (String) -> Unit = { greeting: String ->
{ name: String -> println("$greeting, $name!") }
}
fun main() {
doubleGreeting("Zdravija")("Kotlin")
}

The Next step is Higher-Order Functions and how we can send a parameter with a lambda. HOFs are functions that receive other functions as parameters or return other functions.

// Classical example
fun operation(x: Int, y: Int, block: (Int, Int) -> Int): Int {
return block(x, y)
}
operation(5, 4) { a, b -> a * b } // 20

// Function that returns a function
fun multiplier(factor: Int): (Int) -> Int {
return { it * factor }
}
val double = multiplier(4)
double(5) // 20

In Kotlin, you can change the scope of lambdas by using a concept called receivers. It is the place where inline will show the real force of usage.

Make some simple operations under the list. An extension function can be called on anything and will simply pass this anything as an argument to the lambda passed as a parameter.

fun List<Int>.avg(): Double = if (isEmpty()) 0.0 else sum().toDouble()/size
fun List<Int>.mode(): Int? {
if (isEmpty()) return null
return groupingBy { it }.eachCount().maxByOrNull { it.value }
?.let { (value, _) -> value }
}
fun List<Int>.median(): Double? {
if (isEmpty()) return null
val sorted = sorted()
val mid = sorted.size / 2
return if (size % 2 == 1) sorted[mid].toDouble()
else (sorted[mid - 1] + sorted[mid]) / 2.0 //if even count of list size
}
inline fun <T> T.doWith(block: T.() -> Unit) {
block(this)
}
fun main() {
val numbers = listOf(1, 3, 3, 4, 5, 5, 5, 7, 8)
numbers.doWith {
println("Count : $size")
println("Sum : ${sum()}")
println("Average: ${avg()}")
println("Mean : ${median()}")
println("Mode : ${mode() ?: "No mode (all values unique)"}")
}
}

This syntax .() -> is also a function type with a receiver.

To improve performance, better make a method to be inline (function code is embedded directly into the calling code, instead of generating the calling code). Mode advanced example of Audit extension method of User:

data class User(val id: Long, val username: String, val role: Role)
enum class Role {
ADMIN, USER
}
inline fun <T> User.withAudit(
actName: String, requireAdmin: Boolean = false, block: User.() -> T
): T {
if (requireAdmin && role != Role.ADMIN) {
throw IllegalAccessException(
"User $username is not allowed to perform $actName"
)
}
val start = System.currentTimeMillis()
try {
return block()
} finally {
val duration = System.currentTimeMillis() - start
println("AUDIT | userId=$id | $actName | $duration")
}
}
fun main() {
val user = User(id = 1L, username = "Username", role = Role.ADMIN)
val result = user.withAudit(actName="genRep", requireAdmin=true) {
"Report generated by $username" // this is User
}
println(result)
}

In extension functions, this refers to the object on which it is called, and since we extend the User class, this is actually a reference to the user.

2. Let’s add another extension Update method to the User.

data class UserProfile(var displayName: String, var email: String)
fun User.updateProfile(block: UserProfile.() -> Unit): UserProfile {
val profile = loadProfileFromDb(id)
profile.block()
saveProfileToDb(id, profile)
return profile
}
fun main() {
user.updateProfile {
displayName = "Name", email = "test@test.com"
}
}

T.() -> R means: “Run this lambda as if it were a method of T.”

Inside the block:

  • this = T
  • Public members of T are directly accessible. Access to domain data is explicit and scoped

When to use syntax .() ->

  • You want to avoid using it or variable names.
  • If you write builders, framework API, scope methods, or config blocks.

3. Scenario of Type-Safe Builders (The Receiver Pattern)

Get Aleksei Jegorov’s stories in your inbox

Join Medium for free to get updates from this writer.

This is the most common DSL pattern. By using a “function type with receiver,” you can access the properties of an object directly inside a lambda block without using it or this.

class HTML {
var content: String = ""
fun body(text: String) { content = "<body>$text</body>" }
}
// 'HTML.() -> Unit' is the magic: it makes 'this' refer to the HTML object
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
func main() {
val page = html { body("Hello, DSL!") }
}

4. Scenario of Builder pattern using DSL: lambda with receiver helps to:

  • Clear the configuration blocks of the client
  • Prevents leaking internal object references
  • Matches Kotlin stdlib method like apply (scope function)
class HttpClient {
var baseUrl: String = ""
var timeoutMs: Long = 0
suspend fun request(method: String = "GET", path: String) {
println("Requesting $baseUrl$path with timeout $timeoutMs")
}
suspend fun get(path: String) = request("GET", path)
}
fun httpClient(configure: HttpClient.() -> Unit): HttpClient {
return HttpClient().apply(configure) // pattern with a single receiver
}
fun main() {
// Inside the lambda, `this` as instance, properties are called directly
val client = httpClient {
baseUrl = "https://api.myserver.com"; timeoutMs = 10_000
}

val job1 = async { client.get("/users") } // parallel request
job1.await()
}

5. Scenario of Database Transaction example with helpful lambda:

  • The Transaction becomes the execution context
  • No it noise redundant keyword
  • inline for better performance, allows non-local returns
  • An explicit return makes intent clearer
class Transaction {
fun commit() = println("commit") // for simplicity
fun rollback() = println("rollback")
}
inline fun <T> transaction(block: Transaction.() -> T): T {
return Transaction().run {
try {
block().also { commit() }
} catch (e: Exception) {
rollback()
throw e
}
}
}
fun main() {
val result = transaction {
println("Running DB logic")
"result"
}
println("Output: $result")
}

6. Scenario of Validator.

Custom validation rules that are easy to extend (internal state inside class):

class Validator {
private val errors = mutableListOf<String>()
fun add(condition: Boolean, message: String) {
if (!condition) errors.add(message)
}
fun validate(): List<String> = errors
}
fun validate(block: Validator.() -> Unit): List<String> {
val validator = Validator()
validator.block()
return validator.validate()
}
fun main() {
val errors = validate {
add(1 > 2, "Invalid comparison")
add("".isNotEmpty(), "Empty"); add("".isNotBlank(), "Blank absent")
}
println(errors)
}

Lambda with receiver (this) is the exact mechanism that powers all of Kotlin’s built-in scope functions. 5 usual well-known patterns:

val person = Person()
person.apply { // ← receiver = person
name = "Name"
age = 99
city = "City"
} // returns person (configured)
person.also { p -> // ← receiver = person
println("Created $p")
logToAnalytics(p)
} // returns person (unchanged)
// if return lambda:
with(person) { // ← receiver = person (old-school)
println("$name is $age")
greet()
} // returns whatever the block returns
person.run { // ← receiver = person
"$name ($age) from $city"
} // returns the last expression
person.let { p -> // ← it = person
p.uppercaseName()
p.copy(age = p.age + 1)
} // most often used with ?.let { }
Situation │ Best choice today
──────────────────────────────────────────┼───────────────────────
Configure / build object │ apply { }
Side effect + keep original object │ also { }
Transform / map / compute something │ let / run
Null-safe operations │ let / run / ?.let
Working with already non-null object │ run / with / apply
DSL-style blocks (very clean) │ run / apply
Debug / logging in chain │ also { println(...) }

Conclusion.

Why helpful to use such syntax as T.() -> Unit

  • Readability — no it or variable names
  • Safety — limits API surface
  • Maintainability — easy to refactor

--

--

Aleksei Jegorov
Aleksei Jegorov

Written by Aleksei Jegorov

Java, Kotlin, T-SQL, Angular2, Typescript, ReactJs - developer in Estonia, Tallinn. “Code is like humor. When you have to explain it, it’s bad.” - Cory House

Dev Genius
Dev Genius

Published in Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development