Functions #

Functions are the smallest unit of code that can be named, called repeatedly, and accept input and produce output. In Kotlin, functions are first-class citizens — they can be stored in variables, passed as arguments, and returned from other functions. This opens up the functional programming paradigm that makes Kotlin so expressive. This article covers all function forms in Kotlin in depth: from basic declarations, boilerplate-reducing features like default parameters and single-expression functions, to advanced concepts like higher-order functions, lambdas, tail recursion, and scope functions.

Basic Functions #

Functions are declared with the fun keyword, followed by the name, a parameter list in parentheses, the return type after :, and the body in curly braces.

fun greet(name: String): String {
    return "Hello, $name!"
}

fun add(a: Int, b: Int): Int {
    return a + b
}

// Function without a return value — Unit can be omitted
fun printMessage(message: String): Unit {
    println(message)
}

fun printMessage2(message: String) {  // Unit is implied
    println(message)
}

Calling functions is no surprise:

val greeting = greet("Budi")      // "Hello, Budi!"
val result  = add(10, 25)         // 35
printMessage("Welcome!")

Single-Expression Functions #

If a function body is only one expression, you can write it in a much more concise form using = without curly braces and without return:

// Long form
fun square(x: Int): Int {
    return x * x
}

// Single-expression — identical, but more concise
fun square(x: Int): Int = x * x

// Return type can be inferred if the expression is clear
fun square(x: Int) = x * x

// Other examples
fun max(a: Int, b: Int) = if (a > b) a else b
fun isOdd(n: Int) = n % 2 != 0
fun greeting(name: String) = "Hello, $name! Welcome."

Use single-expression for functions that are genuinely simple and readable on one line. Don’t force this form for logic that needs several steps — that just reduces readability.


Default Parameters #

Parameters can have default values that are used when the argument isn’t provided at the call site. This eliminates the need for excessive function overloading.

fun buildHeader(
    title: String,
    level: Int = 1,
    capitalize: Boolean = false
): String {
    val titleText = if (capitalize) title.uppercase() else title
    val mark = "#".repeat(level)
    return "$mark $titleText"
}

println(buildHeader("Introduction"))                          // # Introduction
println(buildHeader("Introduction", level = 2))               // ## Introduction
println(buildHeader("Introduction", level = 3, capitalize = true)) // ### INTRODUCTION

Avoiding Overloading with Default Parameters #

// ANTI-PATTERN in Java: overloading for optional values
fun sendEmail(to: String, subject: String, body: String) { ... }
fun sendEmail(to: String, subject: String) { sendEmail(to, subject, "") }
fun sendEmail(to: String) { sendEmail(to, "No Subject", "") }

// CORRECT in Kotlin: one function with default parameters
fun sendEmail(
    to: String,
    subject: String = "No Subject",
    body: String = "",
    cc: List<String> = emptyList(),
    priority: String = "normal"
) {
    // implementation
}

// Call with a subset of arguments
sendEmail("[email protected]")
sendEmail("[email protected]", subject = "Important")
sendEmail("[email protected]", priority = "high")

Named Arguments #

Named arguments let you mention parameter names when calling a function. This makes the argument order free and code more explicit.

fun createUser(
    name: String,
    age: Int,
    email: String,
    active: Boolean = true
): String = "[$name | $age | $email | active=$active]"

// Without named arguments — order must be exact
createUser("Budi", 25, "[email protected]")

// With named arguments — free order, clearer intent
createUser(
    name = "Budi",
    email = "[email protected]",
    age = 25,
    active = false
)

Named arguments are especially useful for functions with many Boolean parameters or parameters of the same type, where position alone isn’t enough to tell readers what each argument means:

// ANTI-PATTERN: ambiguous — what do true, false, true mean here?
configure("server1", true, false, true, 5000)

// CORRECT: explicit with named arguments
configure(
    host = "server1",
    ssl = true,
    debug = false,
    cacheEnabled = true,
    port = 5000
)

Vararg — Variable Number of Arguments #

vararg allows a function to accept an unlimited number of arguments for one parameter. Inside the function, the vararg parameter is treated as an Array.

fun sum(vararg numbers: Int): Int = numbers.sum()

println(sum(1, 2, 3))           // 6
println(sum(10, 20, 30, 40))    // 100
println(sum())                  // 0

fun join(separator: String, vararg words: String): String {
    return words.joinToString(separator)
}

println(join(", ", "Kotlin", "Java", "Go"))  // Kotlin, Java, Go

To pass an array to a vararg parameter, use the spread operator (*):

val list = intArrayOf(1, 2, 3, 4, 5)
println(sum(*list))   // 15 — spread the array into varargs

Local Functions #

Kotlin allows functions to be declared inside other functions. Local functions can access variables from the enclosing function (closure).

fun validateForm(name: String, email: String, age: Int): List<String> {
    val errors = mutableListOf<String>()

    // Local function — only accessible inside validateForm
    fun addError(message: String) {
        errors.add(message)
    }

    fun String.isInvalid() = this.isBlank()

    if (name.isInvalid()) addError("Name must not be empty")
    if (email.isInvalid() || !email.contains("@")) {
        addError("Invalid email")
    }
    if (age < 0 || age > 150) addError("Unreasonable age: $age")

    return errors
}

val issues = validateForm("", "not-an-email", -5)
issues.forEach { println("• $it") }
// • Name must not be empty
// • Invalid email
// • Unreasonable age: -5

Local functions are ideal for breaking up logic that’s too long inside a function, without exposing the helper to a wider scope.


Infix Functions #

Infix functions can be called without a dot and without parentheses — like operators. Three requirements: it must be a member function or extension function, have exactly one parameter, and that parameter must not be a vararg or have a default value.

infix fun Int.multipleOf(n: Int): Int = this * n

println(5 multipleOf 3)   // 15
println(2 multipleOf 10)  // 20

// A more descriptive example
infix fun String.notEqualTo(other: String) = this != other

println("kotlin" notEqualTo "java")   // true
println("kotlin" notEqualTo "kotlin") // false

// Building a simple DSL
data class TimeRange(val start: Int, val end: Int)
infix fun Int.untilHour(end: Int) = TimeRange(this, end)

val workHours = 9 untilHour 17
println("${workHours.start}:00 – ${workHours.end}:00")  // 9:00 – 17:00

Extension Functions #

Extension functions let you add functions to existing classes — including classes from third-party or standard libraries — without inheritance or modifying the original class.

// Add to String
fun String.firstName(): String = this.trim().split(" ").first()
fun String.lastName(): String = this.trim().split(" ").last()
fun String.initials(): String = this.trim().split(" ").joinToString("") { it.first().uppercase() }

val fullName = "  Budi Santoso Wijaya  "
println(fullName.firstName())  // Budi
println(fullName.lastName())   // Wijaya
println(fullName.initials())   // BSW

// Add to Int
fun Int.rupiah(): String = "Rp%,d".format(this)
fun Int.thousands(): String = "%,d".format(this)

println(1_500_000.rupiah())  // Rp1,500,000
println(9_876_543.thousands())  // 9,876,543

// Add to List
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null

val list = listOf("first", "second", "third")
println(list.secondOrNull())           // second
println(emptyList<String>().secondOrNull())  // null

Extension functions are compiled into static methods on the JVM — they don’t actually modify the target class and can’t access its private members.


Lambdas and Anonymous Functions #

A lambda is an anonymous function that can be stored in a variable or passed as an argument. It’s the foundation of functional programming in Kotlin.

// Lambda stored in a variable
val square: (Int) -> Int = { x -> x * x }
val greet: (String) -> String = { name -> "Hello, $name!" }
val print: (String) -> Unit = { text -> println(text) }

// Implicit 'it' parameter when there's only one parameter
val double: (Int) -> Int = { it * 2 }
val isPositive: (Int) -> Boolean = { it > 0 }

println(square(5))    // 25
println(greet("Rina"))  // Hello, Rina!
println(double(7))      // 14

Function Types #

Function types are written as (ParameterType) -> ReturnType:

val action: () -> Unit              // no parameters, no return
val convert: (String) -> Int        // one String parameter, returns Int
val operation: (Int, Int) -> Int    // two Int parameters, returns Int
val predicate: (String) -> Boolean  // one String parameter, returns Boolean

Trailing Lambda #

When the last parameter of a function is a lambda, you can write it outside the call parentheses:

fun repeat(times: Int, action: () -> Unit) {
    for (i in 1..times) action()
}

// Without trailing lambda
repeat(3, { println("Hello!") })

// With trailing lambda — cleaner
repeat(3) { println("Hello!") }

// If the lambda is the only argument, the parentheses can be omitted
listOf(1, 2, 3).forEach { println(it) }

Anonymous Functions #

Anonymous functions are similar to lambdas but use explicit fun syntax. Useful when the return type needs to be written explicitly or when you need a return from the function itself (not from the enclosing function):

val multiply = fun(a: Int, b: Int): Int {
    return a * b
}

// Lambdas can't declare an explicit return type
// Anonymous functions can — useful for complex types
val process = fun(input: String): String? {
    if (input.isBlank()) return null
    return input.trim().uppercase()
}

Higher-Order Functions #

A higher-order function is a function that accepts another function as a parameter, or returns a function as a result. This is a key concept enabling very powerful abstractions.

// Accepting a function as a parameter
fun doOperation(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

println(doOperation(10, 5) { x, y -> x + y })   // 15
println(doOperation(10, 5) { x, y -> x * y })   // 50
println(doOperation(10, 5) { x, y -> x - y })   // 5

// Returning a function as a result
fun makeMultiplier(factor: Int): (Int) -> Int {
    return { number -> number * factor }
}

val timesTwo   = makeMultiplier(2)
val timesThree = makeMultiplier(3)
val timesTen   = makeMultiplier(10)

println(timesTwo(7))      // 14
println(timesThree(7))    // 21
println(timesTen(7))      // 70

Function Composition #

Higher-order functions enable composition — combining small functions into pipelines:

fun <A, B, C> compose(f: (B) -> C, g: (A) -> B): (A) -> C = { f(g(it)) }

val addOne: (Int) -> Int = { it + 1 }
val double: (Int) -> Int = { it * 2 }
val square: (Int) -> Int = { it * it }

val addOneThenDouble = compose(double, addOne)
val doubleThenAddOne = compose(addOne, double)

println(addOneThenDouble(5))  // (5+1)*2 = 12
println(doubleThenAddOne(5))  // (5*2)+1 = 11

Tail Recursion — Safe Recursion #

Regular recursion risks StackOverflowError for very large inputs because every call adds a frame to the call stack. Kotlin supports tail recursion with the tailrec keyword — the compiler optimizes it into a loop behind the scenes, so the call stack doesn’t grow.

// ANTI-PATTERN: regular recursion — StackOverflow for large n
fun factorialRegular(n: Long): Long {
    return if (n <= 1) 1 else n * factorialRegular(n - 1)
}

// CORRECT: tail recursion with tailrec
tailrec fun factorial(n: Long, accumulator: Long = 1): Long {
    return if (n <= 1) accumulator else factorial(n - 1, n * accumulator)
}

println(factorial(10))    // 3628800
println(factorial(20))    // 2432902008176640000
// factorial(100000) is also safe — no StackOverflow!

// Fibonacci with tail recursion
tailrec fun fibonacci(n: Int, a: Long = 0, b: Long = 1): Long {
    return when (n) {
        0    -> a
        1    -> b
        else -> fibonacci(n - 1, b, a + b)
    }
}

println(fibonacci(50))  // 12586269025

The tailrec requirement: the recursive call must be the last operation the function performs — no computation may follow the recursive call.


Scope Functions — let, run, with, apply, also #

Scope functions are Kotlin’s built-in higher-order functions that execute a code block within the context of an object. They reduce the need for temporary variables and make code more concise.

data class User(var name: String, var email: String, var active: Boolean = false)

// let — transform an object, suitable for nullable checks
val nameLength = "  Budi Santoso  ".let { it.trim().length }
println(nameLength)  // 11

val user: User? = getUser()
user?.let {
    println("Hello, ${it.name}!")  // only executed if not null
}

// apply — configure an object, returns the object itself
val newUser = User("", "").apply {
    name = "Sari Dewi"
    email = "[email protected]"
    active = true
}

// also — side effects (logging, debugging), returns the object
val list = mutableListOf(3, 1, 4, 1, 5, 9)
    .also { println("Before sort: $it") }
    .also { it.sort() }
    .also { println("After sort: $it") }

// with — operations on an object without naming the object repeatedly
val summary = with(newUser) {
    "User: $name | Email: $email | Active: $active"
}
println(summary)
FunctionContext (this/it)Returns
letitLambda result
runthisLambda result
withthisLambda result
applythisThe object itself
alsoitThe object itself

Summary #

  • Single-expression functions — use fun name(param) = expression for functions whose result is a single expression. More concise than a { return ... } block.
  • Default parameters replace overloading — instead of defining many functions with different signatures, use one function with default values for optional parameters.
  • Named arguments for clarity — always use named arguments when a function has many parameters of the same type or many Booleans, so readers know what each argument means.
  • Local functions to break up logic — if a helper is only needed inside one function, declare it as a local function. It can access variables from the outer scope.
  • Extension functions without inheritance — add new capabilities to any class without inheriting or modifying it.
  • Lambdas are values — lambdas can be stored, passed, and returned like any value. Their function type is written as (ParamType) -> ResultType.
  • Trailing lambdas for readability — when the last argument is a lambda, place it outside the parentheses for a cleaner style that resembles built-in control structures.
  • tailrec for safe recursion — mark recursive functions with tailrec so the compiler optimizes them into loops, avoiding StackOverflowError for large inputs. The recursive call must be the last operation.
  • Scope functions for more concise codelet for nullable handling and transformation, apply for object configuration, also for side effects, with for operations within an object’s context.

← Previous: Loops   Next: Classes →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact