Kotlin Functions, Default and Named Arguments
Kotlin 13 min read
Default arguments remove the overload ladder, named arguments make a call site readable, and both need @JvmOverloads before Java can see them — plus the vararg spread operator and where local functions earn their place.
Kotlin’s function syntax removes two things Java accumulates: the ladder of overloads that exists only to supply defaults, and the call site where four positional booleans mean nothing to a reader. Both replacements have a boundary condition worth knowing, Java callers cannot see either one without help.
Written against Kotlin 1.9.
Declaration and expression bodies
fun add(a: Int, b: Int): Int {
return a + b
}
fun add(a: Int, b: Int): Int = a + b // expression body
fun add(a: Int, b: Int) = a + b // return type inferred
An expression body infers its return type; a block body does not, and omitting the type there means
Unit. That is the source of a specific confusion: a block-bodied function that “returns nothing”
when the author expected inference.
Write the return type explicitly on anything public. It is part of the contract, and inference makes it change silently when the implementation does.
Unit is the equivalent of void and is conventionally omitted:
fun log(message: String) { } // returns Unit
fun log(message: String): Unit { } // identical, and nobody writes this
Default arguments
fun connect(
host: String,
port: Int = 5432,
timeout: Duration = Duration.ofSeconds(30),
ssl: Boolean = true,
) { }
connect("localhost")
connect("localhost", 5433)
connect("localhost", ssl = false)
One function instead of four overloads, and no constructor telescoping. Two details are worth knowing.
Defaults are evaluated at the call site, on each call. A default of Instant.now() produces the
current moment per call, not a value fixed at class-loading: the opposite of a static field, and
usually what you want.
A parameter without a default cannot follow one that has it positionally. connect("localhost", ssl = false) works only because ssl is named; skipping port and timeout positionally is not
possible.
When a parameter is a lambda, put it last and the trailing-lambda syntax applies:
fun retry(times: Int = 3, block: () -> Unit) { }
retry { doWork() } // times defaults to 3
retry(times = 5) { doWork() }
Named arguments
createUser("Ada", "Lovelace", true, false, true) // unreadable
createUser(firstName = "Ada", lastName = "Lovelace",
active = true, admin = false, verified = true) // obvious
Any argument can be named, and named arguments can be reordered freely. The rule with one exception: once you name one, everything after it must also be named, unless the remaining arguments are in their declared positions, which Kotlin 1.4 relaxed to allow.
The case where naming stops being optional is a run of same-typed parameters. Two adjacent
Booleans or two adjacent Strings can be swapped by the caller with no compile error and no test
failure until the behaviour is observed. Naming them removes the class of bug entirely.
Named arguments do not work when calling a Java function. Java bytecode does not retain parameter
names reliably, so Kotlin refuses. Compiling the Java side with -parameters enables it.
The Java interop boundary
@JvmOverloads
fun connect(host: String, port: Int = 5432, ssl: Boolean = true) { }
Without @JvmOverloads, Java sees one method taking all three parameters, the defaults are a
Kotlin-compiler feature and do not exist in the bytecode signature. With it, the compiler generates
the overload ladder for Java callers.
It generates overloads by dropping parameters from the end, so the order of the parameter list
decides which combinations Java gets. @JvmOverloads on a constructor works the same way.
Varargs and the spread operator
fun logAll(vararg messages: String) {
messages.forEach(::println)
}
logAll("a", "b", "c")
val existing = arrayOf("x", "y")
logAll(*existing) // spread — required
logAll("first", *existing, "last") // and it composes
vararg gives an Array<String> inside the function. Passing an existing array needs the * spread
operator; without it the array is one argument of the wrong type and the compiler says so.
Only one vararg per function, and it need not be last, but if it is not, everything after it must
be passed by name.
Spreading copies the array, which is worth knowing in a loop: logAll(*items) allocates a new array
on every call. For a hot path, take the array or a List directly instead of using vararg.
Note that vararg on a primitive type gives the primitive array: vararg n: Int is an IntArray, so
spreading requires an IntArray and not an Array<Int>.
Local functions
fun processOrder(order: Order): Result {
fun validate(field: String?, name: String) {
if (field.isNullOrBlank()) throw ValidationException("$name is required")
}
validate(order.email, "Email")
validate(order.address, "Address")
return Result.ok()
}
A function inside a function, with access to the enclosing scope, so validate could read order
without it being a parameter. That closure is the reason to use one: it removes the parameters a
private helper would need to be passed.
The cost is that a local function is invisible to tests and cannot be reused. Two or three lines called twice in one function is the sweet spot; anything larger belongs at the top level, where it can be tested.
Top-level functions
// file: StringExtensions.kt
package com.example.util
fun slugify(input: String): String = input.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')
No class required. Java sees this as a static method on StringExtensionsKt, renameable with
@file:JvmName("StringUtils") at the top of the file.
Kotlin has no need for the Utils class holding only statics, and creating one is a Java habit worth
dropping.
Extension functions
fun String.toSlug(): String =
lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')
"Hello World!".toSlug() // hello-world
An extension looks like a member and is not one. It compiles to a static function taking the receiver as its first parameter, which has three consequences worth knowing before relying on it.
Extensions are resolved statically. The declared type of the expression decides which extension
runs, not the runtime type, so an extension on Any and one on String dispatch by what the
compiler sees, and there is no overriding.
A member always wins. If the class already has a method with that signature, the extension is never called and the compiler does not warn. Adding a method to a library class can therefore silently change behaviour in code that extended it.
They cannot access private state. An extension sees only the public API of its receiver, which is also why they are safe to write against types you do not own.
Extensions can be nullable-receiver, which is how isNullOrBlank works:
fun String?.orDash(): String = if (isNullOrBlank()) "—" else this
The receiver being String? means the call is legal on a null value and this is nullable inside.
Infix and single-expression style
infix fun Int.pow(exponent: Int): Int = Math.pow(toDouble(), exponent.toDouble()).toInt()
val result = 2 pow 10
infix requires a member or extension function, exactly one parameter, and no vararg or default.
It reads well for a genuine binary operation (to, until, downTo in the standard library) and
badly for anything else. The bar is whether the call reads as a phrase in English.
Infix notation also has lower precedence than arithmetic and higher than the boolean operators, so
1 shl 2 + 3 is 1 shl 5 rather than 4 shl 3. Parenthesise anything non-obvious; an infix
function whose call site needs brackets to be readable was not a good candidate.
More Kotlin in the Kotlin guides, including classes and constructors and properties.
Frequently asked questions
When is the return type inferred?
Only for an expression body (fun f() = ...), a block body
without a declared type returns Unit.
Should I write the return type explicitly?
On anything public, yes. It is part of the contract and inference lets it change silently when the implementation does.
When are default argument values evaluated?
At each call. Instant.now() as a default gives the
current moment per call, not a value fixed once.
Can I skip a middle parameter that has a default?
Only by naming the ones after it. Positional arguments must remain contiguous from the start.
Why can’t Java see my default arguments?
They are a Kotlin-compiler feature and are not in the
bytecode signature. Add @JvmOverloads to generate the overload ladder.
How does @JvmOverloads decide which overloads to generate?
By dropping parameters from the end, so the parameter order determines which combinations Java gets.
Why can’t I use named arguments on a Java method?
Java bytecode does not reliably retain parameter
names. Compile the Java side with -parameters to enable it.
What does the spread operator do?
*array passes an existing array’s elements as separate
vararg arguments. Without it the array is a single argument of the wrong type.
When should I use a local function?
For a short helper that reads the enclosing scope and is called more than once in that function. Anything reusable or worth testing belongs at the top level.
Do I need a Utils class for top-level functions?
No. Kotlin compiles them to statics on a
file-named class, renameable with @file:JvmName. The Java-style utility holder is unnecessary.