1. Introduction
When you are just starting to program, it seems that functions are “a name + parentheses + code inside”. But as soon as you start processing collections, you quickly notice an unpleasant thing: you write the same loop over and over again, changing only a small piece of logic inside it. Today we will learn to pass that piece of logic as a parameter and stop copying loops, as if we were writing a thesis in 2008 and pasting text from someone else’s Word file.
Imagine our educational console project (a practical app without OOP): a simple “expense tracker”, where each expense is Triple(title, category, amount). So far, we can add and display elements using loops. But very soon you will want to: count only “food”, print only expenses greater than 500, find the first expense by category... And this is where “behavior as a parameter” becomes a superpower.
2. Function type: how to read (A) -> B
A function type in Kotlin looks a bit like an arrow from school math: from something to something. The notation (A) -> B is read as: “a function takes A and returns B”. This is a type, i.e., a description of a value that you can store in a val, pass to a function, return from a function — like Int or String, except it is a “function”.
To make it easier, keep a small table in mind:
| Type | Read as | Example meaning |
|---|---|---|
|
“takes Int, returns Boolean” | check (is the number even?) |
|
“takes a string, returns a number” | measurement (string length) |
|
“takes Int, returns nothing useful” | action (printing) |
|
“takes an expense, returns Boolean” | expense filter |
And here is the important word of the day:
predicate is a function of type (T) -> Boolean, i.e., a “validation rule”.
In Kotlin (and in programming in general), predicates show up all the time.
3. Predicate as a value and a function parameter
Predicate in a variable
Let’s do a very basic thing: store a validation rule in a variable and call it. It looks a bit unusual, but it is important to feel: a function can be a value.
fun main() {
val isEven: (Int) -> Boolean = { x -> x % 2 == 0 }
println(isEven(4)) // true
println(isEven(5)) // false
}
Notice two points.
First, on the left we honestly wrote the type: (Int) -> Boolean. It helps both your eyes and the compiler.
Second, we call the variable isEven like a function: isEven(4). That is, it is a “function value”.
In practice, Kotlin can often infer the type itself, but while you are learning, it is better to sometimes write the type explicitly so you do not have to guess what exactly is happening.
Higher-order function: take a predicate and count matches
Now for the key turn: a higher-order function is a function that takes another function as a parameter (or returns one). We will start with the most practical pattern: “count elements that satisfy a condition”.
fun countMatching(items: List<Int>, predicate: (Int) -> Boolean): Int {
var count = 0
for (x in items) {
if (predicate(x)) count++
}
return count
}
fun main() {
val xs = listOf(1, 2, 3, 4, 5, 6)
println(countMatching(xs) { it % 2 == 0 }) // 3
println(countMatching(xs) { it > 3 }) // 3
}
Here we did something very important: there is one loop, and the “condition” changes. In one call we passed “even”, in another — “greater than 3”. That is, we stopped making separate functions countEven, countGreaterThan3, countSomethingElse, and instead made one function + different rules.
This is exactly “passing behavior”.
Generics: make countMatching universal for any T
Right now, our function can count only Int. But in the project we have expenses, and they are not Int at all. This is where generics come in, which we met in the collections block: we will make the function universal.
fun <T> countMatching(items: List<T>, predicate: (T) -> Boolean): Int {
var count = 0
for (item in items) {
if (predicate(item)) count++
}
return count
}
Now T is “some type”. The compiler will substitute the correct type depending on which list you pass.
And this is useful not only in toy learning tasks. This is literally how half of the Kotlin standard library is built: there are lots of functions that take predicates and apply them to collection elements. For now, we are implementing our own versions by hand so your brain understands the mechanics before you switch to the “autopilot” of standard functions.
4. Practical project: expense tracking on MutableList
Minimal expense model
Let’s agree on a minimal expense model (no classes, because OOP will be later). We will take Triple<String, String, Int>: title, category, amount.
fun makeExpense(title: String, category: String, amount: Int): Triple<String, String, Int> {
return Triple(title, category, amount)
}
fun main() {
val expenses: MutableList<Triple<String, String, Int>> = mutableListOf()
expenses.add(makeExpense("Coffee", "food", 250))
expenses.add(makeExpense("Taxi", "transport", 900))
println(expenses.size) // 2
}
A fact that often surprises beginners: a collection can be val, and still remain mutable. That is, you cannot reassign the reference, but you can add/remove elements. This is normal practice in Kotlin: val expenses = mutableListOf(...) and then expenses.add(...).
And the add/remove operations for MutableList are standard: add, remove, clear, etc.
Now let’s use our universal function:
fun main() {
val expenses = mutableListOf(
Triple("Coffee", "food", 250),
Triple("Taxi", "transport", 900),
Triple("Lunch", "food", 600)
)
val foodCount = countMatching(expenses) { it.second == "food" }
println(foodCount) // 2
}
We are not introducing typealias yet (that will be much later), so it.second looks a bit “inhuman”. You can make it more readable with destructuring.
Destructuring in a predicate
Destructuring a Triple is familiar from earlier lectures (about Pair/Triple). Let’s use it right inside the lambda — this makes it easier to understand what we are checking.
fun main() {
val expenses = listOf(
Triple("Coffee", "food", 250),
Triple("Taxi", "transport", 900)
)
val bigFood = countMatching(expenses) { (_, category, amount) ->
category == "food" && amount >= 500
}
println(bigFood) // 0
}
Yes, there is an underscore _ — it means “the component exists, but I do not need it”. And now we can read the lambda almost like a sentence: “category is food and amount >= 500”.
5. Action parameter: (T) -> Unit
forEachIf: predicate + action
A predicate answers the question “does this element match?”. But often you need something else: “what should we do with each matching element?”. For example: print it, build up a report string, add it to another list (though we will talk about full pipelines later).
For this, the second popular function type is used: an action, usually (T) -> Unit.
Unit means “we return nothing useful”. It is not “emptiness” and not null. It is like “ok, I’m done”.
Let’s make a function forEachIf: iterate over a list, and for matching items call the action.
fun <T> forEachIf(
items: List<T>,
predicate: (T) -> Boolean,
action: (T) -> Unit
) {
for (item in items) {
if (predicate(item)) action(item)
}
}
And use it:
fun main() {
val xs = listOf(1, 2, 3, 4)
forEachIf(xs, { it % 2 == 0 }, { println("even=$it") })
// even=2
// even=4
}
Here you see “two lambdas in a row”, and that is fine. A bit later we will discuss the writing style so it does not turn into spaghetti.
Apply forEachIf to expenses: printing selected elements
Now let’s do “print expenses by condition”. You will immediately get a practical feeling: we write one loop and reuse it many times.
fun printExpense(e: Triple<String, String, Int>) {
val (title, category, amount) = e
println("$title | $category | $amount")
}
fun main() {
val expenses = listOf(
Triple("Coffee", "food", 250),
Triple("Taxi", "transport", 900),
Triple("Lunch", "food", 600)
)
forEachIf(expenses, { it.second == "food" }, ::printExpense)
// Coffee | food | 250
// Lunch | food | 600
}
Yes, here you suddenly see ::printExpense. This is a “function reference” (callable reference) — formally, that is the next lecture topic, but for now you can think of it as “I am passing a ready-made function instead of a lambda { e -> printExpense(e) }”. If you prefer less magic for now, replace it with a lambda — the meaning will not change.
6. Searching a collection: an honest contract via T?
Counting and printing are fine. But often you need a search: “find the first element that matches”. And here it is important to be honest: the element may not be found. So the result must be T?.
Let’s make our own version of findFirstOrNull (yes, the standard library has similar functions, but we are training our hands and brain).
fun <T> findFirstOrNull(items: List<T>, predicate: (T) -> Boolean): T? {
for (item in items) {
if (predicate(item)) return item
}
return null
}
Now an example with expenses:
fun main() {
val expenses = listOf(
Triple("Coffee", "food", 250),
Triple("Taxi", "transport", 900)
)
val firstBig = findFirstOrNull(expenses) { it.third >= 500 }
println(firstBig) // (Taxi, transport, 900)
}
If there were no such element, we would get null. And that forces us to handle the result carefully:
fun main() {
val expenses = listOf(Triple("Coffee", "food", 250))
val found = findFirstOrNull(expenses) { it.second == "transport" }
val title = found?.first ?: "nothing found"
println(title) // nothing found
}
This approach plays nicely with null-safety: the T? type directly says “may be absent”.
7. Why “pass behavior” is better than “pass a flag”
A very typical beginner mistake is trying to control a function’s logic with boolean flags. For example: “count expenses by category, but if onlyBig = true, then count only large ones”. Then onlyFood, onlyTransport, minAmount, maxAmount appear, and you suddenly write 30 ifs, and the function turns into a combine harvester.
Let’s compare two ideas. The bad one (flags) I will show briefly so as not to traumatize your psyche:
fun countExpenses(expenses: List<Triple<String, String, Int>>, onlyBig: Boolean): Int {
var count = 0
for (e in expenses) {
val ok = if (onlyBig) e.third >= 500 else true
if (ok) count++
}
return count
}
The problem is not even this snippet, but the future: there will be many flags, and you will have to combine options.
And now the good style: one place where the loop lives, and any condition is a separate predicate:
fun main() {
val expenses = listOf(
Triple("Coffee", "food", 250),
Triple("Taxi", "transport", 900),
Triple("Lunch", "food", 600)
)
val bigCount = countMatching(expenses) { it.third >= 500 }
val foodCount = countMatching(expenses) { it.second == "food" }
println(bigCount) // 2
println(foodCount) // 2
}
This kind of code is easier to extend: a new condition appears — you add a new lambda. No need to rewrite the function and add flags.
8. Names for function parameters and lambda readability
While you are practicing, there are two harmful scenarios.
- The first is when everything is it, and you no longer understand who “it” is, especially if there are two nested lambda blocks nearby. Then it is better to honestly name the parameter: expense, x, item.
- The second is when you name parameters meaninglessly: f, g, h. That is convenient for mathematicians and villains from the world of cryptography, but in application code it is better to speak plainly.
In higher-order functions, the most common names are predicate and action. These are not magic words, just a widely accepted style: you read the signature and immediately understand the roles.
For example, this signature reads almost like a sentence:
fun <T> forEachIf(items: List<T>, predicate: (T) -> Boolean, action: (T) -> Unit)
“For each element, if predicate — perform action.” That’s it.
9. Mini diagram: what happens when calling a higher-order function
Sometimes your brain gets stuck on the thought “how can you pass a function into a function”. Imagine that you are not passing a function, but a “behavior configuration”, like a mode switch. Only the switch is not Boolean, but a full piece of code.
Here is a simple flowchart (the logic is the same for countMatching and forEachIf):
flowchart TD
A[Start: there is a list items] --> B{Take the next element}
B -->|element exists| C["Call predicate(item)"]
C --> D{predicate returned true?}
D -->|yes| E["Perform action: count++ or action(item)"]
D -->|no| F[Do nothing]
E --> B
F --> B
B -->|no elements| G[Finish: return the result / stop]
And here is the key idea: predicate(item) is a normal call; it is just that predicate arrived as a parameter.
10. Typical mistakes
Mistake #1: confusing Boolean and (T) -> Boolean.
When you see predicate, remember: it is not a “ready-made true/false answer”; it is a “function that can answer” for each element. Therefore, you must call predicate: predicate(item). If you try to write if (predicate), the compiler will reasonably be surprised: “why are you trying to use a function as a boolean value?”.
Mistake #2: making the predicate a “combine harvester” (check + printing + mutating external state).
Technically, a lambda can check, print, and increment a counter. But then you lose predictability: by the type (T) -> Boolean nobody expects that it also prints to the console inside. It is better to separate responsibilities: the predicate only answers the question, the action only performs the side effect (printing, logging, adding to a list).
Mistake #3: returning T instead of T? in search.
If a function “searches for an element”, it must account for the “not found” case. Returning an “empty element” or Triple("", "", 0) is a bad contract: you mix a real result with a placeholder. T? is more honest, and Kotlin is designed to safely work with such cases via ?. and ?:.
Mistake #4: overusing it and losing readability.
it works great in short lambdas of 1–2 expressions. But if the condition becomes composite, destructuring appears, nested checks — it is better to name the parameter explicitly. Your future brain (and the reviewer’s brain) will thank you, and you will have fewer bugs.
GO TO FULL VERSION