Functions & Lambdas

Higher-order functions, lambda syntax, extension functions, inline, and the pieces that make Kotlin feel functional.

Higher-order fns Lambda & it Function refs inline Extensions infix Operators Local functions
๐Ÿ”ผ

Higher-Order Functions

A higher-order function is one that takes another function as a parameter, returns one, or both. Kotlin makes this natural because function types are ordinary types โ€” (Int, Int) -> Int is a real type you can declare a variable with, pass around, and store in a data structure, the same as String or List<Int>. This is the foundation everything else on this page builds on: lambdas, extension functions, and most of the collections API all come down to functions accepting functions.

Function types as parameters (T) -> R
// The parameter `operation` has type (Int, Int) -> Int:
// a function taking two Ints and returning an Int
fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

val sum = calculate(3, 4) { x, y -> x + y }
println(sum) // 7

// A named function reference works anywhere a lambda would
fun multiply(x: Int, y: Int) = x * y
println(calculate(3, 4, ::multiply)) // 12
Returning a function Factory
// A function can return another function: the returned lambda
// closes over `factor` from the enclosing scope
fun multiplier(factor: Int): (Int) -> Int {
    return { n -> n * factor }
}

val triple = multiplier(3)
println(triple(5))  // 15
println(triple(10)) // 30

val double = multiplier(2)
println(double(5))  // 10: independent closure, own captured `factor`
๐Ÿน
Coming from Go: Go already treats functions as first-class values โ€” func(int, int) int is a real type, and returning closures is idiomatic Go too. The syntax difference is mostly cosmetic: Kotlin's (Int, Int) -> Int reads left-to-right as "these params, this return," where Go's func(a, b int) int keeps the C-style declaration order.
๐Ÿชถ

Lambda Syntax & it

A lambda in Kotlin is always written inside curly braces, never with a fun keyword or parentheses around the parameter list โ€” that's how the compiler tells a lambda literal apart from a block. When a lambda takes exactly one parameter and you don't need to name it, Kotlin lets you drop the parameter declaration entirely and refer to it as it. This isn't magic scoping; it's a single, well-known implicit name the compiler makes available.

Anatomy of a lambda { params -> body }
// Full form: parameter list, arrow, body โ€” all inside { }
val add: (Int, Int) -> Int = { a, b -> a + b }
println(add(2, 3)) // 5

// The last expression in the body is the return value: no "return" needed
val describe: (Int) -> String = { n ->
    val parity = if (n % 2 == 0) "even" else "odd"
    "$n is $parity"   // this is the lambda's result
}
println(describe(4)) // "4 is even"
it: the implicit single parameter it
val numbers = listOf(1, 2, 3, 4, 5)

// Named parameter form
numbers.filter { n -> n % 2 == 0 }

// it: implicit name, valid only for single-parameter lambdas
numbers.filter { it % 2 == 0 }   // [2, 4]
numbers.map { it * it }             // [1, 4, 9, 16, 25]

// Nested single-param lambdas shadow `it` โ€” name explicitly to avoid confusion
numbers.map { outer ->
    numbers.filter { it > outer }.count()
}
๐Ÿ’ก
Use it for short, single-purpose lambdas where the meaning is obvious from context โ€” filter { it > 0 } reads fine. Once a lambda spans more than a line or two, or you're nesting lambdas, name the parameter explicitly. it inside it is a real readability trap, and linters like detekt flag it for exactly that reason.
โžก๏ธ

Trailing Lambda Syntax

When a function's last parameter is a function type, Kotlin lets you move the lambda argument outside the parentheses โ€” and if the lambda is the only argument, you can drop the parentheses entirely. This single syntax rule is what makes Kotlin's standard library functions (let, run, apply, collection operators) and DSL builders (Gradle's Kotlin DSL, Compose) read like built-in language constructs instead of ordinary function calls.

Moving the lambda outside the parens Trailing
fun repeat3(times: Int, action: (Int) -> Unit) {
    for (i in 0..<times) action(i)
}

// Fully explicit call: lambda passed as an ordinary argument
repeat3(3, { i -> println("iteration $i") })

// Idiomatic: the trailing lambda moves outside ()
repeat3(3) { i -> println("iteration $i") }

// When the lambda is the ONLY argument, () can be dropped entirely
fun runTwice(action: () -> Unit) { action(); action() }
runTwice { println("hi") }   // no () before the { at all
Why this powers Kotlin's DSLs DSL shape
// Trailing lambdas compose: a function taking a receiver lambda
// (see extension functions below) plus trailing syntax is the whole
// trick behind Gradle's build.gradle.kts and Kotlin HTML builders
fun html(build: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.build()
    return sb.toString()
}

val page = html {
    append("<h1>")
    append("Hello")
    append("</h1>")
}
println(page) // <h1>Hello</h1>
๐Ÿน
Coming from Go: Go has no equivalent syntax sugar โ€” a callback is always passed as an ordinary trailing argument inside the parens: doWork(3, func(i int) { ... }). Trailing lambda syntax is a purely Kotlin ergonomic win; there's nothing to unlearn here, just a shorter way to write the same call shape.
๐Ÿ”—

Function References

The :: operator turns an existing named function, method, property, or constructor into a value of function type, without wrapping it in a lambda by hand. It's most useful when a lambda would just forward its arguments to an existing function โ€” { x -> foo(x) } and ::foo do the same thing, and the reference is shorter and states the intent more directly.

Top-level, member, and constructor references ::
fun isEven(n: Int) = n % 2 == 0

val numbers = listOf(1, 2, 3, 4, 5, 6)

// Top-level function reference: no lambda wrapper needed
println(numbers.filter(::isEven))  // [2, 4, 6]

// Member reference: Type::method, bound at each call to its receiver
val words = listOf("kotlin", "go", "rust")
println(words.map(String::uppercase))  // [KOTLIN, GO, RUST]

// Bound reference: fixed to one specific instance
val greeting = "hello"
val lengthOfGreeting = greeting::length
println(lengthOfGreeting())  // 5

// Constructor reference: ::ClassName builds instances
data class Point(val x: Int, val y: Int)
val coords = listOf(1 to 2, 3 to 4)
val points = coords.map { (x, y) -> Point(x, y) }
// or, pointfree: coords.map(::Point) doesn't fit here since Point
// needs destructuring first, but a single-arg constructor works directly:
data class Id(val value: Int)
val ids = listOf(1, 2, 3).map(::Id)
๐Ÿ’ก
Reach for a function reference when a lambda would do nothing but forward its parameters unchanged. list.map { it.uppercase() } and list.map(String::uppercase) compile to essentially the same thing โ€” the reference form just says "apply this existing function" without the extra visual noise of a parameter name you never use for anything else.
๐Ÿ”ฅ

inline, noinline, crossinline

Every lambda you write is, under the hood, an object implementing a function interface โ€” passing one to a higher-order function normally means allocating that object and paying a virtual call to invoke it. Marking a function inline tells the compiler to paste the function's body, and the lambda's body, directly into every call site at compile time. No object, no virtual call โ€” and as a bonus, return inside the lambda can return from the enclosing function, not just the lambda (a "non-local return").

inline: erase the call overhead inline
// Without inline: block is an object, action() is a virtual call
fun measure(block: () -> Unit): Long {
    val start = System.nanoTime()
    block()
    return System.nanoTime() - start
}

// inline: the compiler pastes measure()'s body AND the lambda's body
// directly at the call site โ€” no allocation, no virtual dispatch
inline fun measureInline(block: () -> Unit): Long {
    val start = System.nanoTime()
    block()
    return System.nanoTime() - start
}

// A non-local return: only legal because measureInline is inline โ€”
// the lambda's body is pasted into processItems' own body
fun processItems(items: List<Int>) {
    measureInline {
        for (item in items) {
            if (item < 0) return  // returns from processItems, not just the lambda
            println(item)
        }
    }
}
noinline & crossinline Modifiers
// noinline: opt one specific lambda parameter OUT of inlining
// (needed when you must store the lambda or pass it onward as an object)
inline fun process(
    inline action: () -> Unit,
    noinline callback: () -> Unit
) {
    action()
    storeForLater(callback)   // callback must be a real object to store it
}

// crossinline: still inlined, but forbids non-local return โ€”
// required when the lambda runs inside another execution context
// (like a different thread or a nested function) where "return" from
// the outer function wouldn't make sense
inline fun runInBackground(crossinline action: () -> Unit) {
    Thread { action() }.start()
    // without crossinline, `return` inside action would try to return
    // from runInBackground after it may have already returned
}
๐Ÿ’ก
The Kotlin standard library marks nearly every scope function and collection operator (let, map, filter, forEach) as inline, which is exactly why non-local return works inside a forEach lambda but not inside a regular function-type parameter you declare yourself. See Gotchas for the flip side: what happens when you rely on non-local return without realizing the function isn't inline.
โš ๏ธ
Don't reach for inline by default. It increases compiled bytecode size at every call site (code duplication, effectively), so it only pays off for small, frequently-called functions โ€” the exact profile of scope functions and collection operators. See Performance for when inlining actually helps versus when it just bloats the binary.
๐Ÿงฉ

Extension Functions

An extension function lets you add a member-call-syntax function to a type you don't own โ€” including types from the standard library or a third-party dependency โ€” without subclassing it or modifying its source. Inside the function body, this refers to the receiver, the instance the extension was called on. It's syntactic sugar over an ordinary static function taking the receiver as its first parameter; nothing about the original type actually changes; there is no reflection or monkey-patching involved.

Adding a function to an existing type fun T.name()
// Extends String โ€” a type you can't modify โ€” with a new member
fun String.isPalindrome(): Boolean {
    val cleaned = this.lowercase().filter { it.isLetterOrDigit() }
    return cleaned == cleaned.reversed()
}

println("racecar".isPalindrome())         // true
println("A man, a plan, a canal: Panama".isPalindrome()) // true
println("kotlin".isPalindrome())           // false
Resolved statically, not virtually Static dispatch
open class Animal
class Dog : Animal()

// Extension functions are chosen based on the DECLARED (compile-time)
// type, not the runtime type โ€” unlike overridden member functions
fun Animal.describe() = "an animal"
fun Dog.describe() = "a dog"

val animal: Animal = Dog()  // declared type: Animal, runtime type: Dog
println(animal.describe())  // "an animal" โ€” resolved by the declared type Animal

val dog: Dog = Dog()
println(dog.describe())     // "a dog"
โš ๏ธ
This static resolution is the sharpest gotcha with extension functions: they look like polymorphic member calls but aren't. If you need runtime-type dispatch, use a real member function or override, not an extension. Extensions are for adding utility behavior to types, not for modeling polymorphism.
๐Ÿน
Coming from Go: Go can't add a method to a type it doesn't own either (no methods on types from other packages), so the usual workaround is a free function, IsPalindrome(s string) bool. Extension functions give you the same capability but with method call syntax at the use site: s.isPalindrome() reads better than IsPalindrome(s) when you're chaining several calls together.
๐Ÿ”€

infix Functions

Marking a single-parameter member or extension function infix lets you call it without the dot or parentheses: a to b instead of a.to(b). It's a narrow feature with a narrow purpose โ€” making a small set of calls read like natural-language operators โ€” and the compiler enforces the constraint that keeps it from getting out of hand: exactly one parameter, no defaults, no vararg.

Declaring and using infix functions infix
// to, from the standard library, builds a Pair โ€” the most common
// infix function you'll use without even thinking about it
val entry = 1 to "one"   // same as 1.to("one"), produces Pair(1, "one")

// Defining your own
infix fun Int.until(other: Int): IntRange = this..<other
println((1 until 5).toList())  // [1, 2, 3, 4]

class Vector2(val x: Double, val y: Double) {
    infix fun dot(other: Vector2) = x * other.x + y * other.y
}
val v1 = Vector2(1.0, 2.0)
val v2 = Vector2(3.0, 4.0)
println(v1 dot v2)  // 11.0
๐Ÿ’ก
Reserve infix for functions that genuinely read like an operator or a natural sentence โ€” to, and, zip, step. Slapping it on ordinary methods just to save a dot and parens usually hurts readability rather than helping it; the standard library uses it sparingly for exactly this reason.
โž•

Operator Overloading

Kotlin lets you define what +, *, [], and a fixed set of other operators mean for your own types, by implementing a function with a specific name and the operator modifier. It's not open-ended operator invention โ€” you can't create a new symbol โ€” but the closed list covers arithmetic, comparison, indexing, and invocation, which is enough to make math-like types (vectors, matrices, money) read naturally.

Arithmetic operators operator fun plus
data class Vector2(val x: Double, val y: Double) {
    // a + b desugars to a.plus(b)
    operator fun plus(other: Vector2) = Vector2(x + other.x, y + other.y)

    // a * scalar desugars to a.times(scalar)
    operator fun times(scalar: Double) = Vector2(x * scalar, y * scalar)

    // unary minus: -a desugars to a.unaryMinus()
    operator fun unaryMinus() = Vector2(-x, -y)
}

val a = Vector2(1.0, 2.0)
val b = Vector2(3.0, 4.0)
println(a + b)    // Vector2(x=4.0, y=6.0)
println(a * 2.0)  // Vector2(x=2.0, y=4.0)
println(-a)       // Vector2(x=-1.0, y=-2.0)
Indexing & invocation get / invoke
class Grid(val width: Int, val height: Int) {
    private val cells = IntArray(width * height)

    // grid[x, y] desugars to grid.get(x, y)
    operator fun get(x: Int, y: Int) = cells[y * width + x]

    // grid[x, y] = v desugars to grid.set(x, y, v)
    operator fun set(x: Int, y: Int, value: Int) {
        cells[y * width + x] = value
    }
}

val grid = Grid(10, 10)
grid[2, 3] = 42
println(grid[2, 3])  // 42

// invoke: makes an instance callable like a function, obj(args)
class Greeter(val greeting: String) {
    operator fun invoke(name: String) = "$greeting, $name!"
}
val greeter = Greeter("Hi")
println(greeter("Ada"))  // "Hi, Ada!" โ€” greeter("Ada") calls invoke
๐Ÿ’ก
Operator overloading is powerful and easy to abuse. Reserve it for types where the operator's mathematical or collection-like meaning is unambiguous โ€” vectors, money, matrices, custom containers. Overloading + to mean something surprising (like triggering a network call) is the kind of thing that makes an interviewer's eyebrow go up.
๐Ÿ“

Local Functions

Kotlin allows a full fun declaration nested inside another function body, not just a lambda. A local function can access and mutate the variables of its enclosing function directly โ€” it closes over them the same way a lambda would โ€” but it reads and debugs like an ordinary named function, which is often clearer than an equivalently-scoped lambda assigned to a local val.

A named helper scoped to one function Local fun
fun validateOrder(items: List<Double>, discount: Double): Double {
    // Local function: only visible inside validateOrder,
    // and it can see `discount` from the enclosing scope directly
    fun applyDiscount(price: Double): Double {
        require(price >= 0) { "price can't be negative" }
        return price * (1 - discount)
    }

    return items.sumOf { applyDiscount(it) }
}

println(validateOrder(listOf(10.0, 20.0, 30.0), discount = 0.1))
// 54.0
๐Ÿ’ก
Reach for a local function over a lambda-assigned-to-val when the helper needs a name that documents its purpose, calls itself recursively, or you simply find fun helperName(...) more readable than val helperName = { ... } with an inferred type. Both close over the same enclosing state; it's purely a readability choice.
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Function type(Int, Int) -> IntA real, first-class type
Lambda literal{ a, b -> a + b }Always in braces; last expr is the return value
Implicit param{ it * 2 }Only for single-parameter lambdas
Trailing lambdaf(x) { ... }Last function-type param moves outside ()
Lambda-only callf { ... }() dropped entirely when the lambda is the only arg
Function reference::functionNamePoints to a top-level function
Member referenceType::methodUnbound; takes a receiver as its first param
Bound referenceinstance::methodFixed to one specific receiver instance
Constructor reference::ClassNameBuilds instances, usable in map/etc.
Inline functioninline fun f(block: () -> Unit)Body pasted at call site; enables non-local return
Opt out of inliningnoinline paramNeeded to store or pass the lambda onward
Inline without non-local returncrossinline paramNeeded when the lambda runs in another context
Extension functionfun T.name() { this... }Resolved by declared (static) type, not runtime type
Infix calla to bExactly one param, no default, no vararg
Operator overloadoperator fun plus(o: T)Enables a + b via a.plus(b)
Indexed accessoperator fun get/setEnables obj[i] and obj[i] = v
Callable instanceoperator fun invoke(...)Enables obj(args)
Local functionfun helper() { ... } inside funCloses over enclosing locals like a lambda would