Higher-Order Functions
A higher-order function is any function that takes another function as a parameter, returns a function, or both. Kotlin treats functions as first-class values, so you can store them in variables, pass them around like any other object, and compose them together. This is what makes idioms like list.filter { it > 0 } or list.map { it * 2 } possible, and it is the foundation for callbacks, strategy patterns, and much of Kotlin’s standard library. Once you understand how function types and lambdas work, higher-order functions stop feeling like magic and become one of the most useful tools in your Kotlin toolbox.
Overview: How Higher-Order Functions Work
In most languages you pass around data — numbers, strings, objects. Kotlin lets you pass around behavior too, because every function has a type. The type (Int, Int) -> Int describes any function that takes two Int values and returns an Int; it does not matter whether that function is named add, multiply, or is an anonymous lambda — as long as its shape matches, it can be assigned to a variable of that type or passed as an argument.
A higher-order function is simply a function whose parameter list or return type includes one of these function types. The classic examples are the collection functions you have probably already used: filter, map, forEach, and fold are all higher-order functions defined in the standard library that accept a lambda describing what to do with each element.
Under the hood, the Kotlin compiler represents a lambda or function reference as an instance of a FunctionN interface (for example Function2<Int, Int, Int> for a two-argument function returning an Int). Creating that instance costs a small object allocation every time the higher-order function is called with a lambda. For small, frequently-called functions like filter or map, Kotlin avoids this cost by marking the function inline: the compiler copies the lambda’s body directly into the call site at compile time, so no function object is ever created. That is why almost every higher-order function in kotlin.collections is declared with the inline keyword. Inlining has a second, non-obvious benefit: it allows a return inside the lambda to exit the enclosing function directly (a "non-local return"), something that is illegal in a lambda passed to a regular, non-inline function — see Common Mistakes below.
Kotlin’s own scope functions — let, run, apply, also, and with — are themselves inline higher-order functions built on exactly the mechanism this lesson covers, which is part of why they read like language keywords even though they are ordinary library functions.
Syntax
A function type is written as a parameter list in parentheses, an arrow, and a return type:
// Function type: describes parameters and return type
(ParamType1, ParamType2) -> ReturnType
// A variable holding a function type
val add: (Int, Int) -> Int = { x, y -> x + y }
// Calling it like a normal function
val result = add(3, 4)
| Part | Meaning |
|---|---|
(Int, Int) |
The parameter types the function accepts, in order |
-> |
Separates the parameter list from the return type |
Int (after ->) |
The return type; use Unit if nothing meaningful is returned |
{ x, y -> x + y } |
A lambda literal — its own parameter names, then ->, then the body |
it |
The implicit name of a lambda’s single parameter when you don’t name one explicitly |
::functionName |
A function reference — points at an existing named function so you can pass it like a value |
When a function type is the last parameter of a higher-order function, Kotlin lets you move the lambda outside the parentheses — this is called trailing lambda syntax, and it’s why list.filter { it > 0 } doesn’t need parentheses around the lambda at all.
Examples
Example 1: A function that accepts a function
fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
fun main() {
val sum = calculate(4, 5) { x, y -> x + y }
val product = calculate(4, 5) { x, y -> x * y }
println("Sum: $sum")
println("Product: $product")
}
Output:
Sum: 9
Product: 20
calculate is a higher-order function: its third parameter, operation, has the function type (Int, Int) -> Int. Each call site passes a different lambda — { x, y -> x + y } and { x, y -> x * y } — so the exact same calculate function performs two entirely different computations depending on what behavior you hand it. Kotlin infers the lambda’s parameter types from operation‘s declared type, so you don’t need to annotate x and y yourself.
Example 2: A function that returns a function
fun multiplier(factor: Int): (Int) -> Int {
return { number -> number * factor }
}
fun main() {
val double = multiplier(2)
val triple = multiplier(3)
println(double(5))
println(triple(5))
}
Output:
10
15
multiplier returns a function instead of a plain value — its return type is (Int) -> Int. Calling multiplier(2) doesn’t multiply anything yet; it produces a new function that remembers factor = 2 through closure, ready to be called later with a single Int. double and triple are independent closures capturing their own factor, which is why double(5) and triple(5) produce different results even though they share the same underlying code.
Example 3: Combining a predicate and an action
data class Student(val name: String, val score: Int)
fun processStudents(students: List<Student>, predicate: (Student) -> Boolean, action: (Student) -> Unit) {
for (student in students) {
if (predicate(student)) {
action(student)
}
}
}
fun main() {
val students = listOf(
Student("Alice", 92),
Student("Bob", 67),
Student("Cara", 81)
)
processStudents(students, { it.score >= 80 }) { student ->
println("${student.name} passed with honors: ${student.score}")
}
}
Output:
Alice passed with honors: 92
Cara passed with honors: 81
processStudents takes two function parameters: predicate decides which students to include, and action decides what to do with each one that passes. Because action is the last parameter, it’s passed with trailing lambda syntax outside the parentheses, while predicate — not being last — stays inside them as an ordinary argument. Separating the "which" from the "what" lets you reuse the same loop for many different filtering-and-acting combinations without rewriting it.
How It Works Step by Step
Walking through calculate(4, 5) { x, y -> x + y } from Example 1:
- Kotlin evaluates the arguments
4and5, and packages the trailing lambda{ x, y -> x + y }as a value of type(Int, Int) -> Int. - Execution enters
calculate‘s body witha = 4,b = 5, andoperationbound to that lambda. operation(a, b)invokes the lambda withx = 4,y = 5, evaluatingx + yto9.- That
9becomescalculate‘s return value, which is assigned tosum. - The whole process repeats for
productwith a different lambda, proving thatcalculate‘s logic ("call operation with a and b") never changes — only the behavior plugged into it does.
Function references work the same way but skip writing a lambda entirely — they point directly at an existing function:
fun isEven(n: Int): Boolean = n % 2 == 0
fun main() {
val numbers = listOf(1, 2, 3, 4, 5, 6)
val evens = numbers.filter(::isEven)
println(evens)
}
Output:
[2, 4, 6]
::isEven creates a function reference of type (Int) -> Boolean — exactly what filter expects — without you having to wrap it in { n -> isEven(n) } yourself. Because filter is declared inline in the standard library, this call compiles down to a plain loop with an if check, with no lambda object or extra function-call overhead at runtime.
Common Mistakes
Mistake 1: Using return inside a lambda passed to a non-inline function
A return statement inside a lambda tries to exit the nearest enclosing function — but for a regular, non-inline higher-order function, the lambda is compiled into a separate object, so there is no enclosing function left to return from. Kotlin catches this at compile time:
fun processNumbers(numbers: List<Int>, action: (Int) -> Unit) {
for (n in numbers) action(n)
}
fun findFirstEven(numbers: List<Int>): Int? {
var result: Int? = null
processNumbers(numbers) { n ->
if (n % 2 == 0) {
return n
}
}
return result
}
fun main() {
println(findFirstEven(listOf(1, 3, 5, 4, 7)))
}
This fails with "’return’ is not allowed here" because processNumbers is an ordinary function — the lambda passed to it is a real object, and a non-local return out of it isn’t well-defined. Marking processNumbers as inline fixes it: the compiler pastes the lambda’s body directly into findFirstEven at compile time, so return n really does return from findFirstEven.
inline fun processNumbers(numbers: List<Int>, action: (Int) -> Unit) {
for (n in numbers) action(n)
}
fun findFirstEven(numbers: List<Int>): Int? {
processNumbers(numbers) { n ->
if (n % 2 == 0) {
return n
}
}
return null
}
fun main() {
println(findFirstEven(listOf(1, 3, 5, 4, 7)))
println(findFirstEven(listOf(1, 3, 5)))
}
Output:
4
null
The first call finds 4, the first even number in the list, and returns immediately, skipping 7 entirely. The second list has no even numbers, so the loop finishes normally and the function falls through to return null.
Mistake 2: Writing a nullable function type without parentheses
Kotlin lets a function type itself be nullable, but the parentheses matter enormously. (String) -> Unit? is a non-null function whose return value happens to be nullable — the function reference itself can never be null. ((String) -> Unit)? is a nullable reference to a function that returns Unit. Mixing these up is a common trap when you want an optional callback:
fun notify(message: String, callback: (String) -> Unit? = null) {
callback(message)
}
Because (String) -> Unit? is a non-null type, assigning null as the default value fails to compile — null is not a valid value of a non-null type, no matter what its return type looks like. The fix is to wrap the whole function type in parentheses before applying the question mark, and call it safely with ?.invoke():
fun notify(message: String, callback: ((String) -> Unit)? = null) {
callback?.invoke(message)
}
fun main() {
notify("System started")
notify("Task complete") { msg -> println("Callback received: $msg") }
}
Output:
Callback received: Task complete
The first call to notify passes no callback, so it defaults to null and callback?.invoke(message) does nothing. The second call supplies a trailing lambda, so the callback is invoked and prints its message.
Best Practices
- Prefer
valfor variables holding functions — you rarely need to reassign which behavior a variable points to. - Use trailing lambda syntax whenever the function type is the last parameter; it is the idiomatic Kotlin style and reads like a mini DSL.
- Reach for a function reference (
::name) instead of a lambda when you are just forwarding to an existing function — it is shorter and avoids re-declaring parameter names. - Declare small, frequently-called higher-order functions as
inlineto avoid per-call object allocation and to allow non-localreturns from their lambdas. - Give function type parameters descriptive names (
predicate,onSuccess,transform) rather than generic ones likef— the parameter name is often the only documentation a caller sees. - When a callback is genuinely optional, make the whole function type nullable with parentheses —
((T) -> Unit)?— and invoke it with?.invoke(...)rather than defaulting to an empty lambda that silently does nothing. - Favor the standard library’s existing higher-order functions (
map,filter,fold,sortedBy) over hand-written loops — they are inlined, well-tested, and communicate intent immediately.
Practice Exercises
- Write a higher-order function
repeatAction(times: Int, action: () -> Unit)that callsactionexactlytimestimes. Use it to print"Hello"three times. - Write a function
compose(f: (Int) -> Int, g: (Int) -> Int): (Int) -> Intthat returns a new function equivalent to applyinggfirst and thenfto its result. Test it withf = { it + 1 }andg = { it * 2 }on the input5— the expected output is11. - Write a function
findFirst(numbers: List<Int>, predicate: (Int) -> Boolean): Int?that returns the first element matchingpredicate, ornullif none match, without using the standard library’s built-infind. Decide whether it needs to beinline, and why.
Summary
- A higher-order function accepts a function as a parameter, returns one, or both.
- Function types are written
(ParamTypes) -> ReturnType; lambdas and function references (::name) are values of that type. - When a function type is the last parameter, Kotlin allows trailing lambda syntax, moving the lambda outside the parentheses.
- The compiler represents lambdas as
FunctionNobjects unless the higher-order function is markedinline, which pastes the lambda’s code directly into the call site and removes the allocation. returninside a lambda only works as a non-local return when the enclosing function isinline; otherwise it’s a compile error.(T) -> R?and((T) -> R)?are different types — only the second lets the function reference itself benull.- Kotlin’s own scope functions and collection functions (
let,filter,map,fold) are higher-order functions built on exactly this mechanism.
