1. When a lambda turns into a “wrapper for the sake of a wrapper”
When you’ve just learned lambdas, there’s a dangerous phase: you want to wrap literally everything in a lambda. It’s like buying a power screwdriver and then using it to twist a bottle cap. Formally it works, but you start getting weird looks — even from the cat and the compiler. Most often the problem shows up when a lambda does exactly one thing: it simply calls another function.
Let’s look at a typical situation. We have a predicate function isShort, and we want to pass it into a higher-order function that expects (String) -> Boolean.
fun isShort(s: String): Boolean = s.length <= 3
fun findFirstOrNull(items: List<String>, predicate: (String) -> Boolean): String? {
for (s in items) if (predicate(s)) return s
return null
}
fun main() {
val words = listOf("kotlin", "go", "java")
val result = findFirstOrNull(words) { s -> isShort(s) } // wrapper lambda
println(result) // go
}
Here the lambda { s -> isShort(s) } doesn’t do anything new: it just “forwards” the argument into isShort. In such cases, Kotlin lets you write it shorter and, as a rule, more readably.
2. What a callable reference :: is and how to read it
A callable reference is a way to turn an existing function (or another “callable”, like a constructor) into a value that you can pass as an argument. Essentially, it’s a “function reference” as a value compatible with a function type. Kotlin treats callable references as one of the ways to “get an instance of a function type”, alongside lambdas.
Syntax looks like this:
- ::isShort — a reference to the function isShort
- String::toInt — a reference to a function/method you can call on String (for now, think of it as a “ready-made conversion from string to number”)
- ::Regex — a reference to a constructor (we’ll mention it in passing, without diving deep)
Today we’re primarily interested in the first form: ::functionName.
How to read ::
A handy mental translation:
- { x -> f(x) } reads as “a lambda that calls f”
- ::f reads as “the function f itself, as a value”
That is, we do not call f, we pass it.
Schematically (a simple “mental map”):
flowchart LR
A["Wrapper lambda
{ x -> f(x) }"] --> B["Callable reference
::f"]
B --> C["Higher-order function
expects (T)->R"]
3. Replacing { x -> f(x) } with ::f
In this section we’ll carefully move from “yeah, it’s shorter” to understanding: when it’s really the same, and when it’s not. It’s important not to fall into a trap here: :: is not a “magic shortener”, it’s a strict feature the compiler checks with types.
Let’s return to the example and rewrite it using a callable reference:
fun isShort(s: String): Boolean = s.length <= 3
fun findFirstOrNull(items: List<String>, predicate: (String) -> Boolean): String? {
for (s in items) if (predicate(s)) return s
return null
}
fun main() {
val words = listOf("kotlin", "go", "java")
val result = findFirstOrNull(words, ::isShort)
println(result) // go
}
The key point: ::isShort fits because the signatures match:
- expected: (String) -> Boolean
- isShort has: (String) -> Boolean
That’s why the compiler calmly “substitutes” the function where a lambda is needed.
4. Signature matching: why :: sometimes doesn’t compile
With callable references there’s one good news and one… also good news, it’s just annoying at first.
Good news: if ::f compiles, then type-wise everything matched.
Second good news: if it doesn’t compile, the compiler saved you because you tried to pass “the wrong behavior”. And it’s better to find out now than after two hours of debugging and philosophical questions about life.
Parameter count doesn’t match
Imagine we want to pass a function that takes two arguments into a place where one is expected.
fun startsWithPrefix(s: String, prefix: String): Boolean = s.startsWith(prefix)
fun usePredicate(items: List<String>, predicate: (String) -> Boolean): Int {
var count = 0
for (x in items) if (predicate(x)) count++
return count
}
fun main() {
val words = listOf("kotlin", "koala", "java")
usePredicate(words, ::startsWithPrefix) // WILL NOT compile: 1 parameter expected, but function has 2
}
Here ::startsWithPrefix has type (String, String) -> Boolean, but (String) -> Boolean is expected. The compiler honestly says: “doesn’t match”.
What to do? In this case, a callable reference does not replace the lambda, because the lambda really adds logic here: “fix prefix”.
fun main() {
val words = listOf("kotlin", "koala", "java")
val prefix = "ko"
val count = usePredicate(words) { s -> startsWithPrefix(s, prefix) }
println(count) // 2
}
And that’s perfectly fine: the lambda does useful work (mixes in an external parameter).
Return type doesn’t match
If (String) -> Boolean is expected but you try to pass a function (String) -> Int, Kotlin also won’t let you create chaos.
fun lengthOf(s: String): Int = s.length
fun usePredicate(items: List<String>, predicate: (String) -> Boolean): Int {
var count = 0
for (x in items) if (predicate(x)) count++
return count
}
fun main() {
val words = listOf("a", "bbb", "cc")
usePredicate(words, ::lengthOf) // WILL NOT compile: Int does not turn into Boolean
}
This isn’t the language “being picky”, it’s basic safety: a predicate must return true/false, not “2, because the length is 2” (even though it sounds like dream logic from a programmer’s nightmare).
5. Callable reference as a value: you can store it in a val and call it
To feel that ::f is really a value, it helps to do it “head-on” once: save the function reference into a variable and then call it.
This is also a great way to stop confusing “pass” and “call”.
fun normalize(s: String): String = s.trim().lowercase()
fun main() {
val f: (String) -> String = ::normalize
println(f(" HeLLo ")) // hello
}
We’re not calling normalize directly; we’re calling f(...), and inside f there’s a reference to normalize.
By the way, Kotlin’s documentation on lambdas and function types lists callable references as a separate way to create a value of a function type. That’s exactly what this is: “a function as a value”.
6. Where :: improves readability, and where it gets in the way
With callable references it’s easy to fall into two extremes.
The first extreme is writing wrapper lambdas everywhere, even when it’s just one line f(x).
The second extreme is replacing everything with ::, even when there’s extra logic. Then the code becomes “too clever”: compact, but unclear.
To make the decision, it helps to keep a simple table in mind:
| Situation | Better | Why |
|---|---|---|
| The lambda calls exactly one function with no changes | ::f | Removes noise { x -> f(x) } |
| You need to fix an external parameter or add a check | lambda { x -> ... } | This is no longer “just a call”, it’s logic |
| The function name explains the step well | ::f | The code becomes self-documenting |
| The function name is bad/abstract (doIt, process) | usually a lambda or rename | Otherwise ::doIt explains nothing |
And yes: if you see ::doIt in code, it’s almost always a signal “the function should be renamed”, not “you need to understand the philosophy of doIt”.
7. Mini example: making a “pseudo-map” with ::
We’re not using the standard collection operations map/filter/find as our main tool yet (that will come later in the plan). So let’s build a learning analogue: a function that applies a transformation to each element of a list using a plain for.
First, let’s write the higher-order function:
fun transformAll(items: List<String>, transform: (String) -> String): List<String> {
val result = mutableListOf<String>()
for (x in items) result.add(transform(x))
return result
}
Now imagine we want to normalize user input: trim spaces and convert to lowercase.
Option 1: wrapper lambda (works, but adds noise)
fun normalize(s: String): String = s.trim().lowercase()
fun main() {
val raw = listOf(" ADD ", " LiSt", " remove ")
val normalized = transformAll(raw) { x -> normalize(x) }
println(normalized) // [add, list, remove]
}
Option 2: callable reference (shorter and more honest)
fun normalize(s: String): String = s.trim().lowercase()
fun main() {
val raw = listOf(" ADD ", " LiSt", " remove ")
val normalized = transformAll(raw, ::normalize)
println(normalized) // [add, list, remove]
}
Here ::normalize is just perfect: it describes the step precisely and contains no “hidden magic”.
8. Embedding :: into an expense-tracker CLI app
Now we’ll make a small but realistic improvement to that console app you built recently about collections and commands add/list/remove. We don’t have classes and objects yet, so we’ll store expenses as a list of pairs: Pair<String, Int>, where first is the title, and second is the amount.
The idea of today’s improvement is simple: we want the “processing steps” code to be readable. Callable references are a great way to make steps “named”.
Basic app functions
First, let’s sketch a minimal foundation (very briefly). There’s nothing new here; it’s just context:
fun addExpense(expenses: MutableList<Pair<String, Int>>, title: String, amount: Int) {
expenses.add(title to amount)
}
fun printExpenses(expenses: List<Pair<String, Int>>) {
if (expenses.isEmpty()) {
println("No expenses yet") // No expenses yet
return
}
for ((title, amount) in expenses) println("$title: $amount")
}
The “normalize command” step as a separate function
Previously, many people write normalization right in main, roughly like this: val cmd = readln().trim().lowercase(). That’s fine, but as the logic grows, you want to extract the step into a function.
fun normalizeCommandLine(line: String): String = line.trim().lowercase()
And this is where callable references start to shine: we can pass this step as a parameter without smearing logic across main.
A higher-order function: “read a line and prepare it”
Let’s make a simple utility: it reads a line, applies a transformation to it, and returns the result. This resembles the pattern “read → prepare → parse” that you already used in topics about input.
fun readPreparedLine(prepare: (String) -> String): String {
val raw = readln()
return prepare(raw)
}
Now in main we can do this:
fun main() {
println("Enter a command:")
val line = readPreparedLine(::normalizeCommandLine)
println("Command: $line")
// If you entered " LiSt ", then you get: Command: list
}
Here ::normalizeCommandLine is a perfect fit: the function already exists, its name is clear, and the signature (String) -> String matches what’s required.
9. :: in predicates: code becomes “conversational”
Callable references work especially nicely with predicates, because a predicate can often be expressed as a single clear function with a good name.
Imagine we store expense titles and want to find the first expense with a short title (for example, “tea”), or conversely, a “long and suspicious” one (for example, “some unclear subscription to something”).
Let’s define the predicate as a named function:
fun isShortTitle(title: String): Boolean = title.length <= 3
And we’ll use our search function from the previous lecture (I’ll include it fully again so we don’t jump across files):
fun findFirstOrNull(items: List<String>, predicate: (String) -> Boolean): String? {
for (s in items) {
if (predicate(s)) return s
}
return null
}
Now a readable call:
fun main() {
val titles = listOf("coffee", "tea", "subscription")
val firstShort = findFirstOrNull(titles, ::isShortTitle)
println(firstShort) // tea
}
If you read this out loud, it becomes almost a normal sentence: “find first or null by the rule isShortTitle”. And that’s exactly why we’re learning :: today: less noise, more meaning.
The function name as part of the documentation
A callable reference amplifies the impact of naming: the function name becomes a “label of behavior”. So :: looks especially good with functions that sound like an action or a rule: normalizeCommandLine, isShortTitle, parseAmountOrNull.
If the function name is bad, :: will only highlight the problem. For example:
fun doIt(s: String): String = s.trim()
// transformAll(items, ::doIt) // technically ok, but readability... well, you get it
If you see that code with :: looks strange, it’s often a sign that you shouldn’t “go back to the lambda”, but simply rename the function.
10. Common mistakes when using callable references ::
Mistake #1: confusing “passing a function” with “calling a function”.
One of the most common confusions looks like this: a student writes findFirstOrNull(words, isShort(words[0])) and is surprised that everything broke. Because isShort(words[0]) is already a Boolean, but you need to pass (String) -> Boolean. In these places, it helps to say out loud: ::isShort — I’m passing it; isShort("go") — I’m calling it.
Mistake #2: trying to replace with :: where the lambda really does extra work.
When you need to “mix in” an external parameter, a callable reference most often doesn’t fit — and that’s normal. The lambda { s -> startsWithPrefix(s, prefix) } is not a wrapper; it fixes prefix. At that moment :: isn’t “worse”; it’s just for a different situation.
Mistake #3: being surprised that the compiler rejects ::f even though “it seems to fit by meaning”.
A callable reference lives in a world of strict types: if (T) -> R is expected, the passed function must have exactly that signature. If it has more parameters or a different return type, Kotlin won’t “guess”. This isn’t language pedantry; it’s protection from bugs that would otherwise show up too late.
Mistake #4: using :: with badly named functions and ending up with unreadable code.
:: almost always improves code only when the function name speaks for itself. If the name is something like process, handle, doStuff, then ::process doesn’t explain what’s happening. The code becomes shorter but not clearer. The correct fix here is naming, not yet another lambda.
Mistake #5: trying to “force :: everywhere” as a programming style.
A callable reference is a tool for targeted simplification. If you start replacing any lambdas with :: and in the process lose the ability to quickly understand the logic, you’ve crossed the line from engineering into the sport of “who can write it shorter”. In real projects, nobody gives awards for winning that sport — but people often get assigned to maintain the result.