1. What is a closure: a lambda with outer context
When you have just met lambdas, it can seem like they are simply “a short function right at the call site.” But reality is a bit trickier: a lambda can read variables that are declared outside, in the surrounding scope. And this is very similar to carrying a backpack: wherever you go, the backpack goes with you (and sometimes there is something heavy and suspicious inside).
Technically, that is exactly a closure: a lambda “closes over” outer variables and can use them. Moreover, this works not only for lambdas, but also for local functions (functions inside a function). In the official Kotlin documentation there is a classic example: the local function dfs uses the variable visited, declared in the outer function — that is a typical capture of context by a closure.
Let’s build intuition with a small example.
fun main() {
val prefix = "[LOG]" // outer variable
val log: (String) -> String = { msg ->
"$prefix $msg" // the lambda uses prefix
}
println(log("start")) // [LOG] start
}
Here prefix lives outside, and log uses it inside. We did not “thread it through as a parameter,” but access is still there.
2. Capturing val vs var: where it’s safe, and where “state” begins
The most “friendly” kind of closure is when a lambda captures a val, i.e., an immutable variable. In that case it is easy to keep the mental model: “the lambda uses a configuration.” It’s almost like a function with an extra parameter, except the parameter is hidden in the environment.
This is often used to create “configured” predicates or mappers. For example, we need a predicate “string is longer than N.” N is the configuration, while the check itself remains pure.
fun longerThan(minLen: Int): (String) -> Boolean {
return { s -> s.length > minLen } // minLen is captured
}
fun main() {
val isLong = longerThan(3)
println(isLong("go")) // false
println(isLong("kotlin")) // true
}
Pay attention to an important psychological point: isLong looks like an ordinary function (String) -> Boolean, and it really does “just check.” No hidden magic.
Mini-diagram: “configuration” as an outer val
flowchart LR
A["minLen=3 (val)"] --> B["lambda (String)->Boolean"]
B --> C["call: isLong('kotlin')"]
C --> D[true]
In general, you can remember a simple rule: capturing a val is most often safe, because the value does not change, and therefore the lambda’s behavior is stable.
Now comes the moment when code turns into a detective story: “why did it work yesterday but not today.” If a lambda captures a var, then it gains access to mutable state, which means:
- the lambda’s behavior can depend on the past,
- the same lambda call can produce different effects if the state changed,
- the worst part: the type (T) -> Boolean does not show this at all.
Let’s look at an “innocent” predicate that suddenly starts counting how many times it was called.
fun main() {
var checks = 0
val isEvenWithCounter: (Int) -> Boolean = { x ->
checks++ // side effect
x % 2 == 0
}
println(isEvenWithCounter(2)) // true
println(isEvenWithCounter(3)) // false
println("checks=$checks") // checks=2
}
Formally, the predicate “checks evenness.” In fact, it also mutates the outer checks. This is hidden state: the reader sees “predicate,” but gets “predicate + counter.”
Sometimes you need this (for example, logging or collecting statistics), but the problem is that it is easy to do accidentally and then spend a long time being surprised by the consequences.
3. Side effects and the “purity” of higher-order functions
When we say “side effect,” we mean an action that goes beyond “returned a value and that’s it.” For example: printing to the console, changing an outer variable, writing to a file, adding to a list. A lambda can be pure (only computes and returns) or impure (does something else too).
It is important to understand: side effects are not forbidden. Kotlin does not come to you at night and take your keyboard away for println. But if a side effect is hidden where you expect a “simple function,” the code becomes less predictable.
Let’s compare two predicates. Both have type (Int) -> Boolean, but one is “quiet,” the other is “talkative.”
fun main() {
val quiet: (Int) -> Boolean = { it > 0 }
val noisy: (Int) -> Boolean = { x ->
println("checking $x") // side effect
x > 0
}
println(quiet(10)) // true
println(noisy(10)) // checking 10
// true
}
One simply returns true/false. The other prints every time. Sometimes this is useful for debugging, but if you pass such a predicate into a higher-order function, “unexpected” prints can clutter output and make program behavior noisy.
Now we will take a higher-order function that looks honest and simple: “count how many elements satisfy the predicate.” At first glance, it is pure: input → computation → result.
fun countMatching(items: List<Int>, predicate: (Int) -> Boolean): Int {
var count = 0
for (x in items) {
if (predicate(x)) count++
}
return count
}
And now we pass a predicate that captures an outer var and mutates it.
fun main() {
val xs = listOf(-1, 2, 3)
var hits = 0
val p: (Int) -> Boolean = { x ->
val ok = x > 0
if (ok) hits++ // side effect: mutate an outer var
ok
}
val result = countMatching(xs, p)
println("result=$result hits=$hits") // result=2 hits=2
}
The problem is not that it “doesn’t work.” It works. The problem is that countMatching can no longer be perceived as “just counts”. It “just counts” only for pure predicates. And with impure predicates, it becomes part of more complex behavior.
And this is the moment when it becomes important to learn to ask yourself a question while reading code: what did this lambda capture from the outside?
4. How to quickly spot closures while reading code
When a lambda appears in code, it is useful not only to understand “what it does,” but also literally “what it touches.” It’s like checking your pockets before doing laundry: if you forget your keys, it will be fun later, but not for you.
There is a simple mental algorithm.
First, look at the lambda parameters: { x -> ... }. Then look at the body: what names are used. If inside you see variables that are not parameters and are not declared inside the lambda (via val/var), then they are taken from the outer scope — i.e., the lambda is a closure.
Local functions behave similarly: if an inner function uses a variable from the outer one, it also closes over it. In the Kotlin documentation example, the local function dfs(current: Person) uses visited, declared outside — it is exactly the same mechanism of capturing outer state.
5. Practice: expense reports and “slippery” state
For several days we have been building a small console app with a list of entities and commands like add/list/remove (you have built similar things with collections and loops). Today we won’t reinvent the world: we will add simple reports to our app, and they are a convenient place to see where closures help and where they get in the way.
Let an “expense” for now be stored without OOP (classes will come much later), as a Triple:
- first — id
- second — category
- third — amount
fun main() {
val expenses = mutableListOf<Triple<Int, String, Int>>()
expenses.add(Triple(1, "food", 120))
expenses.add(Triple(2, "transport", 60))
expenses.add(Triple(3, "food", 300))
println(expenses.size) // 3
}
Useful closure: a filter by minimum amount
Let’s make a function that prints expenses that match a condition. We intentionally use a for loop to stay close to the material of the current day and not drift into “magic” standard-library operations.
fun printExpensesIf(
expenses: List<Triple<Int, String, Int>>,
predicate: (Triple<Int, String, Int>) -> Boolean
) {
for (e in expenses) {
if (predicate(e)) {
println(e)
}
}
}
Now let’s create a predicate via a closure, where minAmount is an outer configuration parameter val.
fun minAmountPredicate(minAmount: Int): (Triple<Int, String, Int>) -> Boolean {
return { e -> e.third >= minAmount }
}
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "transport", 60),
Triple(3, "food", 300),
)
val bigOnly = minAmountPredicate(100)
printExpensesIf(expenses, bigOnly)
// (1, food, 120)
// (3, food, 300)
}
This is a good example of a “correct” closure: minAmount is captured but does not change, and the predicate remains pure.
Dangerous closure: numbering lines via a captured var
Now imagine you want to nicely number lines in a report. The temptation appears: “let’s define var i outside and increment it inside the lambda.” It works… but if you reuse the lambda, you may get unexpected numbering.
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "transport", 60),
)
var i = 1
val printLine: (Triple<Int, String, Int>) -> Unit = { e ->
println("${i}. id=${e.first} ${e.second} ${e.third}")
i++
}
for (e in expenses) printLine(e)
// 1. id=1 food 120
// 2. id=2 transport 60
for (e in expenses) printLine(e)
// 3. id=1 food 120
// 4. id=2 transport 60
}
If you expected the second pass to start again from 1, surprise: i has already been modified. That means printLine now depends on the past, even though by type it is just (Expense) -> Unit.
Sometimes this is acceptable (for example, you do exactly one pass and the lambda is not used anywhere else). But if such a handler is passed to different places, stored in a variable, called from different functions — you get “state that lives longer than you think.”
6. Patterns: how to keep effects visible and local
When problems with closures begin, it is tempting to “ban everything and live in a monastery of pure functions.” In practice, it is better to learn a few calm habits that make code predictable even if side effects are needed.
If there is a counter, let it live where the counting happens
If your goal is to count something, it is better to return it as a value rather than mutate an outer var. This is especially relevant for predicates: a predicate should answer the question true/false, not “and also do a bit of bookkeeping on the side.”
Let’s compare two approaches. First, the “hidden counter”:
fun main() {
val xs = listOf(1, 2, 3, 4)
var evens = 0
val p: (Int) -> Boolean = { x ->
val ok = x % 2 == 0
if (ok) evens++
ok
}
val count = countMatching(xs, p)
println("count=$count evens=$evens") // count=2 evens=2
}
And now a more honest version: count inside and return.
fun countEvens(xs: List<Int>): Int {
var evens = 0
for (x in xs) if (x % 2 == 0) evens++
return evens
}
fun main() {
println(countEvens(listOf(1, 2, 3, 4))) // 2
}
The second option is more banal, but it is impossible to “miss” what is happening.
If an effect is needed, separate action from predicate
If you need both to filter elements and to print — don’t cram printing into the predicate. In the previous lecture we made a function with predicate and action. This is an excellent “architectural micro-habit”: the effect lives where it belongs.
fun forEachIf(
items: List<Int>,
predicate: (Int) -> Boolean,
action: (Int) -> Unit
) {
for (x in items) {
if (predicate(x)) action(x)
}
}
fun main() {
val xs = listOf(1, 2, 3, 4)
val isEven: (Int) -> Boolean = { it % 2 == 0 }
val printEven: (Int) -> Unit = { println("even=$it") }
forEachIf(xs, isEven, printEven)
// even=2
// even=4
}
Notice how much easier it is to read: the predicate is responsible for “which ones,” the action is responsible for “what to do.”
Keep the closure lifetime short
One of the main practical dangers is not capturing a var itself, but that a lambda with captured state starts living “too long.” For example, you put it into a file-level variable, or pass it far away.
Try to keep such lambdas close to where they are used. If you need a counter-lambda only for one report, create it inside the report function and do not return it outside.
This resembles the principle “don’t leave the tap open”: if water is needed only to pour a glass, don’t leave the tap running all night.
7. Common mistakes
Mistake #1: a predicate with side effects masquerades as a “simple check.”
Beginners often put println inside a predicate, increment an outer counter, or change an outer flag, and then wonder why the higher-order function became “noisy” or why results depend on the number of calls. If a function is conceptually a “condition,” try to keep it pure, and move effects into a separate action.
Mistake #2: capturing a var is used as a “quick way to pass state,” and it bites you when reused.
From the outside it looks like: “so what, var i, I’ll number the lines.” But as soon as you run the same code a second time, the counter is not reset because it lives inside the closure. If state must start over on each run, keep it inside the function/loop that constitutes the “run.”
Mistake #3: a “combine-harvester” lambda: it checks, prints, and accumulates statistics.
Such code is hard to test and hard to read: the signature says one thing, but it does a dozen things. It is better to split roles: the predicate returns Boolean, the action performs outward actions, and statistics are computed by a separate function or a separate step.
Mistake #4: while reading code, people don’t notice captures of outer variables.
A very typical situation: a person reads { x -> x > limit }, but doesn’t see that limit is an outer variable that can change somewhere else. A useful habit: when you see a lambda, quickly scan the names inside and ask yourself: “is this a parameter, a local val, or a captured variable?” The same mechanism works for local functions as well, like in examples where an inner function uses an outer visited.