I added idleCol.coerceIn(0, n - 1) so a column index could never go out of
bounds. That exact line became the only crash in the app, and it fired
precisely in the case it was written to defend against: n == 0.
TL;DR — Kotlin’s coerceIn(min, max) does not clamp when the range is
empty: if max < min it throws IllegalArgumentException: Cannot coerce value. So
to an empty rangei.coerceIn(0, n - 1) crashes when n == 0. Write
i.coerceIn(0, (n - 1).coerceAtLeast(0)), or guard n <= 0 explicitly.
The setup
An Android puzzle game with its UI in Jetpack Compose. The board is a single
Canvas that draws n columns; the player taps a column to move a piece, and
between moves an animated piece rests at an « idle » column — an index into
those n columns. The screen’s state lives in a ViewModel and level data
loads asynchronously: when a level opens, Compose renders a first frame with
the initial state — an empty board, n == 0 — and the real level arrives a
frame or two later. The draw code clamped every index defensively before use:
val idleX = colX(idleCol.coerceIn(0, n - 1))
What I expected
I read coerceIn as « clamp »: push the value inside [min, max], returning
the nearest bound if it falls outside. Under that model a degenerate range is
harmless — with n == 0 the bounds are 0 and -1, I get one of them back,
the board draws something slightly wrong for one frame, and nobody notices.
The model felt reasonable because it is what the naive expression does:
minOf(maxOf(i, 0), n - 1) returns -1 without complaint.
What actually happens
coerceIn clamps only when the range is non-empty. When max < min it
throws:
5.coerceIn(0, 2) // 2 — clamps, as advertised
0.coerceIn(0, -1) // what n == 0 turns the "defensive" line into
java.lang.IllegalArgumentException: Cannot coerce value to an empty range:
maximum -1 is less than minimum 0.
I ran both lines against kotlin-stdlib 2.2.21 on JVM 17 — the stdlib jar that
ships inside every Gradle distribution, driven from jshell, so no Kotlin
compiler was needed to check it.

In the app the crash had a strange signature: once per session, on the first
level opened, never again. The first composition of the game screen ran with
the empty initial state, so n == 0 and the draw lambda threw. Opening any
later level was safe, because the state holder still contained the previous
level — n > 0 — while the next one loaded. And the unit tests never saw it,
because every test built a loaded level before touching the board logic; the
fixture quietly guaranteed the one precondition whose absence triggers the
bug. Same trap as my optimal-solver tests that all passed for a solver that
was not optimal: the tests only exercised
states the bug does not live in.
The fix
Two changes. The clamp itself gets a floor on its upper bound, so the range
can no longer be empty:
val safeIdle = idleCol.coerceIn(0, (n - 1).coerceAtLeast(0))
val idleX = colX(safeIdle)
And the input path gets an explicit guard, because with zero columns there is
nothing meaningful to tap:
Modifier.pointerInput(n) {
detectTapGestures { offset ->
if (n <= 0) return@detectTapGestures
val colWidth = size.width / n
onTap((offset.x / colWidth).toInt().coerceIn(0, n - 1))
}
}
The board itself is simply not drawn while n == 0. The coerceAtLeast(0)
only keeps the frame from throwing; it does not make index 0 meaningful on an
empty board, so it needs that draw guard next to it.
Why it works
coerceIn‘s contract is « return a value that lies within the range », not
« return the nearest bound ». When max < min, the range contains no values at
all, so no return value would satisfy the contract — and the stdlib refuses
rather than inventing one. This is documented behaviour: the API reference for
coerceIn
carries an explicit @throws IllegalArgumentException. I had never read past
the signature, because the name reads like clamp and clamping feels like a
total function.
The throw is arguably the better design. The silent alternative,
minOf(maxOf(i, 0), n - 1), hands back -1, which flows onward and detonates
later as an IndexOutOfBoundsException in some list access far from the
cause. coerceIn fails at the exact line where the impossible range was
constructed. But that only helps if the crash surfaces somewhere you look —
and a bug that reproduces once per session, on the very first frame, is close
to the worst case for being seen.
The Compose half generalises beyond this app: the first composition runs with
whatever the state holder returns before any asynchronous load completes.
Draw code that assumes « collections are non-empty by the time we render » has a
real rendered frame in which that assumption is false. Any coerceIn(0,,
size - 1)first(), or [0] in that path runs against the empty default at
least once.
What I did not test
- I only ran the
Intoverload ofcoerceIn(min, max), on the JVM (stdlib
2.2.21, Temurin 17). I did not run theClosedRangeoverload, the other
primitive overloads, or Kotlin/Native and JS targets. - I did not check older stdlib versions; the app that crashed was on a 2.x
stdlib as well. - The once-per-session pattern is specific to a
ViewModelthat outlives the
screen and retains the previous level. With a state holder scoped to the
screen, the crash would fire on every level open instead — likely easier to
catch, but I did not try that variant.
Facts
context: Jetpack Compose Canvas drawing a board of n columns; first frame renders before async state load, so n == 0
problem: i.coerceIn(0, n - 1) throws IllegalArgumentException "Cannot coerce value to an empty range" when n == 0, instead of clamping
solution: i.coerceIn(0, (n - 1).coerceAtLeast(0)) plus an explicit n <= 0 guard before drawing or handling taps
verified_on: 2026-08-17
applies_to: [Kotlin stdlib 2.2.21 on JVM 17, Int overload of coerceIn(min, max)]
does_not_apply_to: [non-empty ranges (normal clamping works), coerceAtLeast/coerceAtMost (single bound, cannot form an empty range)]