Rebounder Tech Blog

Written by the people who actually run these systems in production.

if/else-if as Kotlin's ?.let Return Value Requires else

Published About 4 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

An if/else-if placed last inside Kotlin's ?.let is treated as an expression by position; without an else it can't satisfy exhaustiveness, so it fails to compile.

The short version

An else-less if/else-if chain placed last inside a Kotlin ?.let { ... } block is treated as an expression — the lambda’s return value. Since no value is defined for the case where neither branch matches, it can’t satisfy exhaustiveness without an else, and the compile fails. In a signage device app’s config-sync code, a plain if/else if sitting between a log-only branch and a save-the-setting branch hit this rule and broke the build. The fix: give one condition an early return via return@let, and split the rest off as an independent if statement.

What it looks like

The target is ConfigPoller, which runs on an always-on signage device and syncs its display schedule from a remotely pushed JSON config. When the received JSON contains a schedule object, the code compared the new and existing schedule inside a ?.let block and saved/rescheduled as needed.

// before
cfg.optJSONObject("schedule")?.let { sched ->
    val existing = ScheduleConfig.load(context)
    val newSched = ScheduleConfig(/* ... */)
    // Sanity: enabled with days_mask=0 (no days targeted) means "blank 24/7" — a
    // misconfiguration. A single bad remote config could blank every device; this
    // device-side fail-safe rejects it (keeping the existing setting).
    if (newSched.enabled && newSched.daysMask == 0) {
        Log.w(TAG, "ignored schedule with days_mask=0 (would blank 24/7)")
    } else if (newSched != existing) {
        ScheduleConfig.save(context, newSched)
        ScheduleManager.rescheduleAll(context)
        ScheduleManager.applyCurrentState(context)
        Log.i(TAG, "schedule updated")
    }
}

The author’s intent was a plain branch with no value in mind: “if the setting is dangerous, just log and ignore it; otherwise, if it changed, save it.” But this if/else if sat on the last line of the ?.let lambda, and Kotlin’s compiler decides whether something is a value-returning expression by where it’s placed, not by what’s inside it. Sitting at the end of the lambda made this if/else if a candidate for the lambda’s return value.

Why

Once treated as a value-returning if expression, Kotlin requires every branch to be exhaustive. Here there are only two branches — if (...) and else if (...) — and no value exists for the case where neither condition matches. To a human, “do nothing if neither applies” reads naturally, but as an expression that means “a branch that returns no value is possible,” which the compiler doesn’t allow — hence the compile error.

Both branches of this if end in Unit-returning calls like Log.w(...) and ScheduleConfig.save(...)the author meant it as a statement from the start. It was only its position, the last line of the lambda, that flipped the compiler’s interpretation from statement to expression.

The fix

The days_mask=0 guard condition was given an early return via return@let, splitting the following save logic off as an independent if statement rather than an else if.

// after
cfg.optJSONObject("schedule")?.let { sched ->
    val existing = ScheduleConfig.load(context)
    val newSched = ScheduleConfig(/* ... */)
    if (newSched.enabled && newSched.daysMask == 0) {
        Log.w(TAG, "ignored schedule with days_mask=0 (would blank 24/7)")
        return@let
    }
    if (newSched != existing) {
        ScheduleConfig.save(context, newSched)
        ScheduleManager.rescheduleAll(context)
        ScheduleManager.applyCurrentState(context)
        Log.i(TAG, "schedule updated")
    }
}

Exiting the lambda with return@let means the first if is no longer the lambda’s last expression. The second if (newSched != existing) { ... } also ends up as an independent statement with no else clause, sitting at the end of the lambda. Kotlin allows the case where “the last expression need not return a value” — a standalone if with no else, like this one — so the build compiles.

The days_mask=0 warning and saving a setting change were, from the start, separate concerns: a guard that keeps the existing setting safe from bad input, versus detecting a change and applying it. Splitting them into two independent statements fits the original intent better than forcing them into one expression with an else.

What’s easy to miss

What makes this error tricky is that the compiler treats it as an expression once the position qualifies, even when every branch’s body is just a Unit-returning statement. It doesn’t read the branch bodies and infer “this was probably meant as a statement.” When writing if/else-if inside a scope function whose last expression becomes its return value — ?.let, ?.run, also, and the like — you need to stay aware of whether it truly sits at the end of the block, even when you never intended to use a value.

The source commit message records that after the fix, assembleDebug succeeded on gradle 8.9 + JDK21 with an APK size of 7,570,934B. But that’s a visual build check; the sources make no mention of an automated test added to cover this branch itself.

Frequently asked questions

Q1Why did this if/else-if alone break the compile?

In Kotlin, the last expression inside a ?.let { ... } lambda becomes its return value. An else-less if/else-if there is treated as a value-returning expression; with no value for the unmatched case, it can't be exhaustive without an else, so it fails to compile.

Q2It was written as a statement — why was it treated as an expression?

Kotlin's if can be a statement or an expression; the compiler decides by position, not appearance. On the lambda's last line, it becomes that lambda's return value, so the expression constraint applies. The original branch chose between logging and saving, with no intent to use a value.

Q3Was adding an else the only way to fix it?

Adding an else would also fix it, but here days_mask=0 got an early return via return@let, splitting the following if (newSched != existing) into its own statement. The two conditions were separate concerns — a safety guard and change detection — so two statements fit the intent better.

Q4Was the build confirmed to pass after this fix?

The source commit message records a successful assembleDebug on gradle 8.9 + JDK21, with an APK size of 7,570,934B. That's a visual build check, though — the sources don't mention an automated test added to cover this branch itself.

Environment verified

  • Kotlin 1.9.24 (org.jetbrains.kotlin.android)
  • Gradle wrapper 8.7 (the build check in the commit message used gradle 8.9 + JDK21)
  • Compile error caught and fixed same-day in a 2026-06-17 commit

What this article is based on

  • Kotlin file lines 251-271commit 53aacf0
  • Kotlin file lines 251-274commit 7cb86a3

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.