1. Why vararg needs a contract for an empty set
If a regular function takes two numbers, then the “special case” is usually only one: someone passed the wrong number (or the wrong value). With vararg, you get another axis of reality: the number of arguments can be from zero to infinity (well, almost). And zero is not a “compile error”, it’s a normal call.
Imagine you’re writing a function maxOf(...). In your head it looks like “find the maximum”. And then, suddenly, someone calls maxOf() — and you’re standing there like a developer in the kitchen at 3 AM and asking the kettle: “What maximum of what exactly?”. The kettle is silent, the compiler is silent, and the program may crash if you go for xs[0].
That’s why any vararg function (even the nicest and kindest one) has an important part of its API: a behavior contract for the empty set. And today we’ll learn not to “leave this to chance”, but to design that contract deliberately.
What a function “contract” means
The word “contract” sounds serious, as if a lawyer is about to show up and ask you to sign Terms and Conditions. In programming, a contract is just an agreement: what input is valid and what exactly the function promises to return/do.
For vararg, the contract is especially important because the “empty set” is a legal input. And since it’s legal, either:
- the function must handle it correctly and return some result,
- or the function must honestly say: “there is no result”,
- or the function must fail immediately and loudly, because it’s a misuse error.
And you choose this not by mood, but by the meaning of the task.
2. Contract strategies for an empty set
In this section, it helps not to jump straight into code, but to first pin down the possible behaviors. We’ll use three strategies that show up all the time (and that you’ll choose again and again for your entire life as a developer, even if you end up writing not games but space rockets — they also have vararg, it’s just called a “telemetry packet”).
Below is a small table so your brain can see the “decision map” right away:
| Strategy | Idea | What we return for an empty set | When it fits |
|---|---|---|---|
| Neutral result | There is a natural “default value” | For example 0, "", “print nothing” | Sum, concatenation, printing |
| Nullable result | The result is not defined, so we honestly return “no value” | null (and the result type is T?) | Maximum/minimum, average, “best result” |
| Fail-fast | An empty set is a programmer error; you can’t continue | Throw an exception via require(...) | When having no arguments makes no sense at all |
Now let’s go through each strategy calmly with small examples.
Neutral result
The nicest case is when the operation has a neutral element. For example, the sum of an empty set is logically zero: you added nothing — you got 0. That’s not magic; it’s just convenient math that makes life much easier.
Let’s make a small function for our learning app. Reminder: in the mini project “Guess the Number” we have attempts (numbers the user entered). Sometimes we want to compute their sum — for example, for stats or for a “joke persistence rating”.
fun sumAttempts(vararg attempts: Int): Int {
var sum = 0
for (a in attempts) sum += a
return sum
}
fun main() {
println(sumAttempts(3, 7, 10)) // 20
println(sumAttempts()) // 0
}
Here the contract is obvious and safe: sumAttempts() returns 0, and it doesn’t look like “we just made something up”. It’s the natural outcome.
An important point: a neutral result does not always fit. If you start returning a neutral result where none exists, you’ll accidentally invent “magic numbers”, and they bite later.
...OrNull
Now let’s take the task “find the maximum”. For an empty set, the maximum is not defined. Some try to do “well, let the maximum of an empty set be 0”. And that sounds harmless right up until real data contains negative numbers. Then “max(empty) = 0” starts lying, and you get a bug that’s very hard to notice.
When there is no result, the most honest path in Kotlin is to return null, i.e., make the result type nullable: Int?.
And here a good naming habit helps: the suffix OrNull. It literally tells the reader: “If the result can’t be produced, we’ll return null”. You’ve already seen this style on strings and numbers, for example toIntOrNull().
Let’s make a function bestAttemptOrNull(...) — “best attempt” (in our case, let “best” be the smallest attempt number: the fewer, the better). But to avoid confusion, in the example we’ll just find the minimum: fewer attempts is better.
fun minAttemptOrNull(vararg attempts: Int): Int? {
if (attempts.size == 0) return null
var min = attempts[0]
for (a in attempts) {
if (a < min) min = a
}
return min
}
fun main() {
println(minAttemptOrNull(5, 3, 9)) // 3
println(minAttemptOrNull()) // null
}
Notice the order: first we check size == 0, and only then do we take attempts[0]. This isn’t “style”, it’s protection from crashing.
Now — how to use this without pain. We can print the result to the user by providing a “display value” via Elvis ?: (you’ve already seen it in topics about null):
fun main() {
val best = minAttemptOrNull()
println(best ?: -1) // -1 (conventionally: "no attempts")
}
It’s important to distinguish: -1 here does not mean the function returned -1. The function honestly returned null. And -1 is our decision only for output, because println(null) looks sad and uninformative.
Fail-fast via require(...)
There are situations where an empty set is not “no result”, but a misuse of the function. That is, the problem isn’t user data — it’s that the programmer called the function incorrectly.
And this is where the fail-fast strategy fits: “crash immediately, loudly, and with a clear message”. In Kotlin, require(...) is often used for this; it throws IllegalArgumentException if the condition is not met. This is a standard pattern: require for argument validation, check for state validation.
Let’s write a strict version of minimum: minAttemptStrict(...). Its meaning: “in the app, it’s impossible to look for a minimum if there are no attempts — that’s a logic error”.
fun minAttemptStrict(vararg attempts: Int): Int {
require(attempts.size > 0) { "minAttemptStrict: at least one argument is required" }
var min = attempts[0]
for (a in attempts) {
if (a < min) min = a
}
return min
}
fun main() {
println(minAttemptStrict(5, 3, 9)) // 3
}
Why do we use require instead of writing if ... throw ... by hand? Because require is a recognizable Kotlin standard style, and the reader immediately understands: “this is a precondition check”.
At the same time, it’s important to understand the philosophy: require is not a way to “handle user input errors”. If the user entered something wrong, you usually don’t want to crash with an exception — you want to ask them to enter it again. But if a programmer called minAttemptStrict() with no arguments, that’s a bug in the code, and it’s better to find out about it as early as possible.
3. How to choose a strategy
A bit of self-irony: flowcharts are often drawn when someone wants to look very formal. But in our case, it genuinely helps you avoid guessing from scratch every time.
flowchart TD
A["There is a vararg function"] --> B{"Can an empty set be meaningful?"}
B -->|Yes, there is a neutral result| C["Return a neutral value (e.g., 0 or '')"]
B -->|Yes, but there is no result| D["Return null and name it ...OrNull"]
B -->|No, it's a misuse error| E["require(size > 0) and a clear message"]
And now the key idea: choosing a strategy is part of API design. That is, not “what’s easiest to write”, but “what will be more convenient and safer to use”.
Why xs[0] is a trap
There is one very common mistake that almost everyone makes. It looks like this: you write a function, you need an initial max/min, you take xs[0], and then you’ll “add the check later”. Then you forget, and then someone calls f() and the program crashes.
To avoid falling for it, it helps to keep a simple template in mind:
Handle emptiness first → then use the first element as the starting point.
Here is a neat version of maxOrNull (for variety — maximum):
fun maxScoreOrNull(vararg scores: Int): Int? {
if (scores.size == 0) return null
var max = scores[0]
for (s in scores) {
if (s > max) max = s
}
return max
}
fun main() {
println(maxScoreOrNull(10, 7, 12)) // 12
println(maxScoreOrNull()) // null
}
This code reads like a normal story: “if there are no values — there is no result; otherwise start from the first and look for a bigger one”.
Names as part of the contract
In programming, it’s very easy to do something “formally correct” but “humanly unclear”. So let’s talk about naming separately.
When you name a function maxOf(...), you’re kind of promising: “there will always be a maximum”. If inside it returns null for an empty set, that’s not a catastrophe, but the reader will have to dig into the implementation or documentation.
When you name a function maxOfOrNull(...), you warn in advance: “sometimes null happens — and that’s OK”. This style is very common in the Kotlin ecosystem, and it makes code more readable simply because the contract is visible right in the name.
And if you make a strict version that doesn’t accept an empty set, it’s convenient to add a hint of “strictness” to the name: ...Strict, ...Required, ...NonEmpty. Not because it’s “required by rules”, but because it saves your colleagues (and future you) from surprises.
4. Example from the app: an attempts report
To keep the feeling that we’re developing one project, let’s make a mini utility that prints an attempts report in “Guess the Number”. Here vararg is convenient because we can quickly pass several numbers directly into the call without thinking about arrays.
Meaning: if there are no attempts, we won’t crash — we’ll print “no data”. This is a good example where ...OrNull gives a convenient contract.
fun minAttemptOrNull(vararg attempts: Int): Int? {
if (attempts.size == 0) return null
var min = attempts[0]
for (a in attempts) if (a < min) min = a
return min
}
fun printAttemptsReport(vararg attempts: Int) {
val best = minAttemptOrNull(*attempts)
println("Attempts: ${attempts.size}") // e.g.: Attempts: 3
println("Best attempt: ${best ?: "no data"}") // e.g.: Best attempt: 3
}
fun main() {
printAttemptsReport(5, 3, 9)
printAttemptsReport()
}
There’s a small but important point here: we “proxy” attempts as a vararg further into minAttemptOrNull, so we use *attempts. We discussed this in the lecture about the spread operator, but now it’s useful to see that even in a “contract” topic it appears naturally.
5. require: messages that help
require is good not because it “crashes”. NullPointerException crashes too, and it brings about as much joy as an alarm clock that rang an hour early. require is good because you control: where to fail and with what message.
A message should answer two questions: what was wrong and what was expected. In the example:
require(attempts.size > 0) { "minAttemptStrict: at least one argument is required" }
It includes the function name (so you can quickly find the culprit) and a human expectation (“at least one argument is required”). That kind of text helps a lot during debugging.
One more thing: require throws IllegalArgumentException. This is worth remembering at least because if you accidentally wrap everything in try/catch (Exception), you might “hide” the real error.
6. Typical mistakes
Mistake #1: the function crashes on xs[0] because you forgot about the empty set.
This is a classic: you wrote a min/max search, took the first element as the starting point — and everything is fine, until someone calls f(). The fix is simple: either do an early return null and use the type T?, or put require(size > 0) before the first indexed access.
Mistake #2: a “magic value” instead of an honest null.
Returning 0 as “the maximum of an empty set” looks convenient, but that convenience is deceptive. If real data can be negative, you’ll get a lie without a single exception — and that’s the nastiest kind of bug. For such tasks, it’s better to use ...OrNull and return null, and set the “print value” externally via ?:.
Mistake #3: using require(...) to handle user input.
require is not “ask the user to enter again”, it’s “the program cannot continue”. If the user entered nonsense, you usually want to explain gently and repeat the input. Reserve require for programmer errors: when the function contract is violated and continuing is meaningless.
Mistake #4: an unclear name hides the contract.
If a function is named as if the result always exists (maxAttempt, bestScore), but on an empty set it returns null or crashes — that’s a surprise. The suffix OrNull makes the contract readable. And if an empty set is forbidden, it’s good to reflect that in the name (Strict, NonEmpty) and in the require message.
Mistake #5: require without a message, or with a message like "Invalid input".
Formally, the code works, but when the error happens you’ll get useless diagnostics. Write the message so it helps fix the call: what was expected, what was wrong, and where to look.
Mistake #6: forgetting that vararg always allows f() — and that’s part of the API.
Sometimes a developer “in their head” assumes: “this function will always be called with at least one argument”. But Kotlin does not guarantee that: an empty call compiles. So the empty-case contract must be encoded directly in the code: via a neutral value, null, or require.