Higher-Order Functions #
Kotlin treats functions as first-class citizens — meaning functions can be stored in variables, passed as arguments, and returned from other functions. This isn’t a rarely used academic feature; in fact, almost the entire Kotlin Standard Library API is built on this concept. Every time you write .filter { it > 0 }, .map { it.nama }, or ?.let { kirim(it) }, you’re using a higher-order function. Understanding how it works from the inside opens the ability to write clean abstractions, more concise code, and expressive APIs. This article covers function types, lambdas, anonymous functions, inline functions, and idiomatic functional programming patterns in Kotlin.
Function Types #
In Kotlin, every function has a type. A function type is written in the format (ParameterTypes) -> ReturnType.
// A regular function
fun tambah(a: Int, b: Int): Int = a + b
// Its function type: (Int, Int) -> Int
// A variable storing a function
val operasi: (Int, Int) -> Int = ::tambah // reference to a function
val kali: (Int, Int) -> Int = { a, b -> a * b } // a lambda
// A function without parameters: () -> Unit
val salam: () -> Unit = { println("Hello!") }
// A function with one parameter: (String) -> Boolean
val cekPanjang: (String) -> Boolean = { teks -> teks.length > 5 }
// A nullable function type — a function that may be null
val opsional: ((Int) -> String)? = null
// A function type with a receiver — like an extension function
val gandakan: Int.() -> Int = { this * 2 }
println(5.gandakan()) // 10
flowchart TD
A["Function Type"] --> B["(ParameterTypes) -> ReturnType"]
B --> C["No parameters\n() -> Unit"]
B --> D["One parameter\n(String) -> Boolean"]
B --> E["Many parameters\n(Int, Int) -> Int"]
B --> F["Nullable\n((Int) -> String)?"]
B --> G["With a receiver\nInt.() -> Int"]Calling a Function Type #
val hitung: (Int, Int) -> Int = { a, b -> a + b }
// Two ways to call
val hasil1 = hitung(3, 4) // the regular way: 7
val hasil2 = hitung.invoke(3, 4) // explicit invoke: 7
// A nullable function type must be checked before calling
val callback: (() -> Unit)? = null
callback?.invoke() // safe — doesn't crash if null
Lambda Expressions #
A lambda is the most common way to define an anonymous function in place. Lambdas are written in curly braces { }.
Lambda Syntax #
// Full syntax
val tambah: (Int, Int) -> Int = { a: Int, b: Int -> a + b }
// Types can be inferred from the variable declaration
val tambah: (Int, Int) -> Int = { a, b -> a + b }
// Or types on the parameters, without the variable annotation
val tambah = { a: Int, b: Int -> a + b }
// A one-parameter lambda: use 'it'
val kuadrat: (Int) -> Int = { it * it }
val kuadrat = { n: Int -> n * n } // without the variable annotation
// A lambda without parameters
val sapa: () -> Unit = { println("Hello!") }
// A multi-line lambda — the last value is the return value
val prosesAngka: (Int) -> String = { angka ->
val doubled = angka * 2
val formatted = "Result: $doubled"
formatted // this is returned, without 'return'
}
Trailing Lambdas #
Kotlin has an important convention: if the last parameter of a function is a function type, the lambda can be written outside the parentheses.
// Without a trailing lambda (verbose)
daftar.filter({ it > 0 })
daftar.map({ it * 2 })
// With a trailing lambda (idiomatic)
daftar.filter { it > 0 }
daftar.map { it * 2 }
// When there are other parameters before the lambda
daftar.fold(0, { acc, n -> acc + n }) // without trailing
daftar.fold(0) { acc, n -> acc + n } // with trailing
// If the lambda is the only argument, the parentheses can be omitted
run({ println("Halo") }) // with parentheses
run { println("Halo") } // trailing lambda — cleaner
// Example with a custom function
fun ulangi(kali: Int, aksi: () -> Unit) {
for (i in 0 until kali) aksi()
}
ulangi(3, { println("Halo") }) // without trailing
ulangi(3) { println("Halo") } // with trailing — cleaner
it — The Implicit Parameter #
When a lambda has only one parameter, Kotlin provides the implicit name it.
// Explicit
val panjang: (String) -> Int = { teks -> teks.length }
// With it
val panjang: (String) -> Int = { it.length }
// it in higher-order functions
listOf("apel", "jeruk", "mangga").filter { it.startsWith("a") }
listOf(1, 2, 3, 4, 5).map { it * it }
// ANTI-PATTERN: it in nested lambdas — ambiguous
daftar.map {
it.nama.let {
it.uppercase() // this it is a String, not the list element
}
}
// CORRECT: give explicit names when there's potential ambiguity
daftar.map { item ->
item.nama.let { nama ->
nama.uppercase()
}
}
Higher-Order Functions #
A higher-order function is a function that takes a function as a parameter, returns a function, or both.
Functions as Parameters #
// A function that takes a function as a parameter
fun terapkan(angka: Int, operasi: (Int) -> Int): Int {
return operasi(angka)
}
val hasil1 = terapkan(5) { it * 2 } // 10
val hasil2 = terapkan(5) { it * it } // 25
val hasil3 = terapkan(5, ::factorial) // reference to another function
// A function with two function parameters
fun kombinasi(
angka: Int,
transformasi: (Int) -> Int,
format: (Int) -> String
): String {
val diubah = transformasi(angka)
return format(diubah)
}
val hasil = kombinasi(
angka = 42,
transformasi = { it * 2 },
format = { "Value: $it" }
)
// "Value: 84"
// Multiple callbacks — a common pattern for async or events
fun unduhData(
url: String,
onSukses: (String) -> Unit,
onGagal: (Exception) -> Unit,
onSelesai: () -> Unit
) {
try {
val data = fetch(url)
onSukses(data)
} catch (e: Exception) {
onGagal(e)
} finally {
onSelesai()
}
}
unduhData(
url = "https://api.example.com/data",
onSukses = { data -> tampilkan(data) },
onGagal = { e -> log.error("Failed: ${e.message}") },
onSelesai = { sembunyikanLoading() }
)
Functions as Return Values #
// A function that returns a function — a factory function
fun buatPenambah(n: Int): (Int) -> Int {
return { x -> x + n }
}
val tambah5 = buatPenambah(5)
val tambah10 = buatPenambah(10)
println(tambah5(3)) // 8
println(tambah10(3)) // 13
// A function that returns a predicate
fun buatFilter(minimum: Int): (Int) -> Boolean {
return { it >= minimum }
}
val lebihDari10 = buatFilter(10)
val lebihDari100 = buatFilter(100)
listOf(5, 15, 8, 120, 50).filter(lebihDari10) // [15, 120, 50]
listOf(5, 15, 8, 120, 50).filter(lebihDari100) // [120]
// A strategy picker — the Strategy Pattern with higher-order functions
fun buatFormatter(gaya: String): (Double) -> String = when (gaya) {
"rupiah" -> { nilai -> "Rp ${"%,.0f".format(nilai)}" }
"dolar" -> { nilai -> "${"$%.2f".format(nilai)}" }
"persen" -> { nilai -> "${(nilai * 100).toInt()}%" }
else -> { nilai -> nilai.toString() }
}
val formatRupiah = buatFormatter("rupiah")
val formatDolar = buatFormatter("dolar")
println(formatRupiah(15_000_000.0)) // Rp 15.000.000
println(formatDolar(15_000_000.0)) // $15000000.00
Closures #
Lambdas in Kotlin are closures — they can access and modify variables from the enclosing scope. This differs from Java where captured variables must be effectively final.
// A lambda capturing a variable from the outer scope
fun buatCounter(): () -> Int {
var hitungan = 0
return {
hitungan++ // modifies a variable from the outer scope
hitungan
}
}
val counter1 = buatCounter()
val counter2 = buatCounter() // a separate counter
println(counter1()) // 1
println(counter1()) // 2
println(counter1()) // 3
println(counter2()) // 1 — counter2 is independent of counter1
// A closure in a practical context
fun buatAkumulator(nilai awal: Double = 0.0): Pair<(Double) -> Unit, () -> Double> {
var total = awal
val tambah: (Double) -> Unit = { total += it }
val ambil: () -> Double = { total }
return tambah to ambil
}
val (tambahBelanja, totalBelanja) = buatAkumulator()
tambahBelanja(150_000.0)
tambahBelanja(75_000.0)
tambahBelanja(200_000.0)
println(totalBelanja()) // 425000.0
Closures capturing mutable variables can cause unexpected side effects, especially in concurrent contexts. When using closures in different coroutines or threads, make sure the captured variable is protected with the right synchronization mechanism, or useStateFlow/AtomicIntegerinstead.
Inline Functions #
Every time you call a higher-order function with a lambda, Kotlin creates a new object for the lambda — adding memory and function call overhead. inline instructs the compiler to copy the function body (and its lambdas) directly to the call site, eliminating this overhead.
The Problem Without inline #
// Without inline: each call creates a new lambda object
fun <T> ukurWaktu(blok: () -> T): T {
val mulai = System.currentTimeMillis()
val hasil = blok()
println("Time: ${System.currentTimeMillis() - mulai}ms")
return hasil
}
// In bytecode, this is roughly equivalent to:
// val lambda = object : Function0<T> { override fun invoke(): T = ... }
// ukurWaktu(lambda)
// — a new object is created on every call
With inline #
// With inline: the function and lambda bodies are copied to the call site
inline fun <T> ukurWaktu(blok: () -> T): T {
val mulai = System.currentTimeMillis()
val hasil = blok()
println("Time: ${System.currentTimeMillis() - mulai}ms")
return hasil
}
// The call:
val hasil = ukurWaktu { hitungKompleks() }
// The compiler transforms it into (roughly):
// val mulai = System.currentTimeMillis()
// val hasil = hitungKompleks() // the lambda body is copied directly
// println("Time: ${System.currentTimeMillis() - mulai}ms")
// — no lambda object is created
noinline and crossinline #
// noinline: mark a lambda that must not be inlined
// (e.g., because it's stored or passed to another function)
inline fun proses(
aksi: () -> Unit,
noinline callback: () -> Unit // this one isn't inlined
) {
aksi()
simpanCallback(callback) // needs a real object reference
}
// crossinline: the lambda may be inlined but can't use non-local returns
inline fun jalankanAsync(crossinline blok: () -> Unit) {
Thread {
blok() // no non-local returns allowed here
}.start()
}
Non-local Returns #
One unique capability of lambdas in inline functions is the non-local return — a return inside the lambda can stop the enclosing function, not just the lambda itself.
// Without inline: a return in a lambda only exits the lambda
fun cariPertama(daftar: List<Int>, predikat: (Int) -> Boolean): Int? {
daftar.forEach { angka ->
if (predikat(angka)) return angka // ERROR if forEach isn't inline
}
return null
}
// forEach is inline, so this return stops the cariPertama function
fun cariPertama(daftar: List<Int>, predikat: (Int) -> Boolean): Int? {
daftar.forEach { angka ->
if (predikat(angka)) return angka // non-local return: exits cariPertama
}
return null
}
// A real example
fun prosesHinggaDitemukan(daftar: List<String>): String {
daftar.forEach { item ->
if (item.startsWith("TARGET")) return item // exits prosesHinggaDitemukan
println("Skipping: $item")
}
return "Not found"
}
Anonymous Functions #
Besides lambdas, Kotlin supports anonymous functions — unnamed functions written with the fun syntax. The main difference: a return in an anonymous function always exits the anonymous function itself, not the enclosing function.
// Lambda
val kuadrat1: (Int) -> Int = { it * it }
// Anonymous function — more explicit syntax
val kuadrat2 = fun(n: Int): Int { return n * n }
val kuadrat3 = fun(n: Int) = n * n // expression version
// The return behavior difference
fun prosesLambda(daftar: List<Int>) {
daftar.forEach {
if (it == 3) return // non-local return: exits prosesLambda!
println(it)
}
println("Done") // never executed if there's an element == 3
}
fun prosesAnonFunc(daftar: List<Int>) {
daftar.forEach(fun(angka: Int) {
if (angka == 3) return // returns from the anonymous function only
println(angka)
})
println("Done") // always executed
}
prosesLambda(listOf(1, 2, 3, 4, 5))
// Output: 1, 2 (returns at 3, "Done" doesn't appear)
prosesAnonFunc(listOf(1, 2, 3, 4, 5))
// Output: 1, 2, 4, 5, Done (3 is skipped, but it continues)
Function References #
Besides lambdas, you can reference existing functions using the :: operator.
fun kaliDua(n: Int) = n * 2
fun cekPositif(n: Int) = n > 0
fun formatAngka(n: Int) = "Number: $n"
val angka = listOf(-3, -1, 0, 2, 4, 7)
// Without function references
angka.filter { cekPositif(it) }.map { kaliDua(it) }.map { formatAngka(it) }
// With function references — cleaner
angka.filter(::cekPositif).map(::kaliDua).map(::formatAngka)
// Method references on an instance
val teks = listOf(" apel ", " jeruk ", " mangga ")
teks.map(String::trim) // method reference on a type
teks.map { it.trim() } // equivalent
// Constructor references
data class Produk(val nama: String)
val namaProduk = listOf("Laptop", "Mouse", "Keyboard")
val produk = namaProduk.map(::Produk)
// [Produk("Laptop"), Produk("Mouse"), Produk("Keyboard")]
// References to member functions
data class User(val nama: String, val aktif: Boolean)
val users = listOf(User("Andi", true), User("Budi", false), User("Clara", true))
users.filter(User::aktif) // reference to a property
users.map(User::nama) // reference to a property
Building Abstractions with Higher-Order Functions #
Higher-order functions let you build powerful and reusable abstractions.
Retry Logic #
// A retry abstraction: try again on failure
fun <T> retry(
kali: Int,
jeda: Long = 0L,
blok: () -> T
): T {
var eksepsiTerakhir: Exception? = null
repeat(kali) { percobaan ->
try {
return blok()
} catch (e: Exception) {
eksepsiTerakhir = e
println("Attempt ${percobaan + 1} failed: ${e.message}")
if (jeda > 0) Thread.sleep(jeda)
}
}
throw eksepsiTerakhir!!
}
// Usage
val data = retry(kali = 3, jeda = 1000L) {
ambilDataDariApi() // will be tried 3 times on failure
}
The Transaction Pattern #
// A database transaction abstraction
fun <T> transaksi(koneksi: Connection, blok: (Connection) -> T): T {
koneksi.autoCommit = false
return try {
val hasil = blok(koneksi)
koneksi.commit()
hasil
} catch (e: Exception) {
koneksi.rollback()
throw e
} finally {
koneksi.autoCommit = true
}
}
// Usage
val hasilTransfer = transaksi(db) { conn ->
kurangiSaldo(conn, dariAkun, jumlah)
tambahSaldo(conn, keAkun, jumlah)
catatHistori(conn, dariAkun, keAkun, jumlah)
}
Pipelines and Function Composition #
// Function composition: combine two functions into one
infix fun <A, B, C> ((A) -> B).then(g: (B) -> C): (A) -> C = { a -> g(this(a)) }
val bersihkan: (String) -> String = { it.trim() }
val besarkan: (String) -> String = { it.uppercase() }
val tambahPrefix: (String) -> String = { ">>> $it" }
val proses = bersihkan then besarkan then tambahPrefix
println(proses(" halo dunia ")) // ">>> HALO DUNIA"
// A pipeline for data transformation
fun <T> T.pipe(vararg transformasi: (T) -> T): T =
transformasi.fold(this) { acc, fn -> fn(acc) }
val hasil = " kotlin is fun ".pipe(
{ it.trim() },
{ it.uppercase() },
{ it.replace(" ", "_") }
)
// "KOTLIN_IS_FUN"
Memoization #
// Cache function results for the same inputs
fun <T, R> ((T) -> R).memoize(): (T) -> R {
val cache = mutableMapOf<T, R>()
return { input ->
cache.getOrPut(input) { this(input) }
}
}
// A recursive Fibonacci function without memoization: O(2^n)
fun fibonacci(n: Int): Long = when (n) {
0, 1 -> n.toLong()
else -> fibonacci(n - 1) + fibonacci(n - 2)
}
// With memoization: O(n)
val fibMemo: (Int) -> Long = { n: Int ->
when (n) {
0, 1 -> n.toLong()
else -> fibMemo(n - 1) + fibMemo(n - 2)
}
}.let { fn ->
val cache = mutableMapOf<Int, Long>()
{ n: Int -> cache.getOrPut(n) { fn(n) } }
}
Idiomatic Functional Programming Patterns #
Use Built-in Functions Instead of Manual Loops #
val karyawan = listOf(
Karyawan("Andi", "Engineering", 15_000_000.0),
Karyawan("Budi", "Marketing", 12_000_000.0),
Karyawan("Clara", "Engineering", 18_000_000.0)
)
// ANTI-PATTERN: an imperative loop
var totalGaji = 0.0
for (k in karyawan) {
if (k.departemen == "Engineering") {
totalGaji += k.gaji
}
}
// CORRECT: functional style
val totalGaji = karyawan
.filter { it.departemen == "Engineering" }
.sumOf { it.gaji }
// ANTI-PATTERN: a loop to find an element
var karyawanTertinggi: Karyawan? = null
for (k in karyawan) {
if (karyawanTertinggi == null || k.gaji > karyawanTertinggi!!.gaji) {
karyawanTertinggi = k
}
}
// CORRECT:
val karyawanTertinggi = karyawan.maxByOrNull { it.gaji }
Avoid Side Effects in Transformations #
// ANTI-PATTERN: a side effect inside map
val hasil = mutableListOf<String>()
daftar.map { item ->
hasil.add(item.nama) // a side effect in map!
item.proses()
}
// CORRECT: separate transformation and side effects
val diproses = daftar.map { it.proses() }
val namaSaja = daftar.map { it.nama }
diproses.forEach { simpan(it) } // side effects in forEach, not map
Use takeIf and takeUnless #
// takeIf: return the object if the condition holds, null if not
// takeUnless: the opposite
// ANTI-PATTERN:
val input = editText.text.toString()
val validInput = if (input.isNotBlank()) input else null
// CORRECT: takeIf is more expressive
val validInput = editText.text.toString().takeIf { it.isNotBlank() }
// takeUnless: return the object if the condition does NOT hold
val produkAktif = produk.takeUnless { it.dihapus }
// Combining with let
editText.text.toString()
.takeIf { it.isNotBlank() }
?.trim()
?.let { input -> simpanData(input) }
When to Use Higher-Order Functions #
Use higher-order functions if:
✓ You have the same logic with variations in the "middle"
(Template Method pattern → higher-order function)
✓ You need callbacks for async operations or events
✓ You want to separate the what from the how (swappable strategies)
✓ You're building a DSL or fluent API
✓ Resource management abstractions (open-use-close)
Avoid them if:
✗ The logic is simple and clearer written directly
✗ The lambda is too long (> 10 lines) — extract into a named function
✗ Lambda nesting deeper than two levels — refactor into separate functions
✗ The team isn't familiar and the code becomes harder to debug
Summary #
- Function types are written as
(ParamTypes) -> ReturnType. Functions are values in Kotlin — storable in variables, passable as arguments, and returnable from other functions.- Lambdas are written in
{ }. The implicititparameter is available when there’s only one parameter. Use explicit names (item,user) when the lambda is more than one line or there’s nesting.- Trailing lambdas: if a function’s last parameter is a function type, the lambda can be written outside the parentheses —
filter { }notfilter({ }). This is a Kotlin convention that should always be followed.- Closures: lambdas can capture and modify variables from the outer scope. Unlike Java which requires captured variables to be
effectively final.inlineeliminates lambda object allocation overhead by copying the function body to the call site. Use it for higher-order functions frequently called in hot paths.- Non-local returns: a
returninside a lambda from aninlinefunction can stop the enclosing function. Usereturn@functionNamefor an explicit local return.- Anonymous functions (
fun(x: Int) = x * 2) behave differently from lambdas:returnis always local to the anonymous function itself, never non-local.- Function references (
::fungsi,Kelas::method) make code cleaner when a lambda merely forwards arguments to one other function.takeIfreturns the object if the condition holds,nullotherwise.takeUnlessis the opposite. Both replace theif (kondisi) objek else nullpattern.- Higher-order functions are the foundation of powerful abstractions like retry logic, the transaction pattern, composition pipelines, and memoization — patterns that make code more declarative and reusable.