Kotlinの?.let末尾のif/else-ifは式扱いでelse必須になる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Kotlinの?.letブロックの最後に置いたif/else-ifは、書き方が文であっても位置によって式として扱われ、elseが無いと網羅性(exhaustive)を満たせずコンパイルエラーになる。
結論
Kotlinの?.let { ... }ブロックの最後にelseの無いif/else-ifチェーンを置くと、そのラムダの戻り値になる「式」として扱われる。どの分岐にも一致しない場合の値が無いために網羅性(exhaustive)を満たせず、コンパイルエラーになる。 サイネージ端末アプリの設定同期処理で、ログを出すだけの分岐と設定を保存する分岐をif/else ifで単純に書いたところ、この扱いに引っかかってビルドが通らなくなった。直し方は、片方の条件をreturn@letで早期returnさせ、残りを独立したif文として切り離すこと。
症状になりうる状態
対象は、常時稼働のサイネージ端末が遠隔からJSON形式のconfigを受け取って自分の表示スケジュールを同期するConfigPoller。受信したJSONにscheduleオブジェクトが含まれるとき、?.letブロックの中で新旧のスケジュールを比較し、必要なら保存・再スケジュールしていた。
// 直す前
cfg.optJSONObject("schedule")?.let { sched ->
val existing = ScheduleConfig.load(context)
val newSched = ScheduleConfig(/* ... */)
// サニティ: enabled なのに days_mask=0(全曜日非対象)は「24/7 黒画面」になる誤設定。
// 誤った遠隔 config 1 つで全端末が真っ黒になるのを端末側 fail-safe で弾く(既存設定を維持)。
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")
}
}
書いた本人の意図は「危険な設定ならログだけ出して無視する。そうでなくて変更があれば保存する」という、値を返すつもりの無いただの分岐だった。だがこのif/else ifは?.letのラムダの最後の行に置かれており、Kotlinのコンパイラはブロックの中身ではなくその式が置かれている位置で「これは値を返す式かどうか」を判断する。ラムダの最後に置かれた時点で、このif/else ifはラムダ全体の戻り値として扱われる対象になった。
原因
値を返すif式として扱われると、Kotlinは全ての分岐を網羅していること(exhaustive)を要求する。ここではif (...)とelse if (...)の2つの分岐しかなく、どちらの条件にも一致しない場合に返す値が存在しない。人間から見れば「どちらにも当てはまらなければ何もしない」で自然だが、式としては「値を返さない分岐がありうる」ことになり、コンパイラはこれを許さずコンパイルエラーにする。
このifはどちらの分岐もLog.w(...)やScheduleConfig.save(...)のようなUnitを返す関数呼び出しで終わっており、書いた本人にとっては最初から文として書いたつもりのコードだった。ラムダの最後の行という位置だけが、コンパイラの解釈を「文」から「式」へ切り替えた。
直し方
days_mask=0のガード条件をreturn@letで早期returnさせ、後続の保存処理をelse ifではなく独立したif文として切り離した。
// 直した後
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")
}
}
return@letでラムダを抜けることで、1つ目のifはもうラムダの最後の式ではなくなる。2つ目のif (newSched != existing) { ... }も、else節を持たない独立した文としてラムダの最後に置かれる形になった。Kotlinは「最後の式が値を返さなくてもよい」ケース(このif自体もelse節が無い単独のif文)を許容するため、コンパイルが通るようになった。
days_mask=0の警告と、設定変更の保存は、そもそも「危険な入力を弾く既存設定維持のガード」と「変更を検知して反映する」という別の関心事だった。elseで無理に1つの式へまとめるより、2つの独立した文に分けたほうが、コンパイラの要求に合うだけでなく元の意図にも近い形になっている。
見落としやすい前提
このエラーが厄介なのは、分岐の中身をどちらもUnitを返す文だけで揃えていても、位置が式になる条件を満たしてしまえばコンパイラは式として扱うことだ。分岐の中身を読んで「これは文のつもりだろう」と判断してくれるわけではない。?.let・?.run・alsoのような、最後の式が戻り値になるスコープ関数の内側でif/else-ifを書くときは、値を使うつもりが無くても、それが本当にブロックの最後に来ているかを意識する必要がある。
根拠のコミットメッセージには、修正後にgradle 8.9 + JDK21でassembleDebugを実行しAPKサイズ7,570,934Bで成功したという記録があるが、この分岐自体を対象にした自動テストが追加された記述はない。コンパイルが通ること自体がこの種のエラーに対する一次的な検査であり、テストで再現・固定する手当てはこのコミットの範囲には含まれていなかった。
よくある質問
Q1なぜこのif/else-ifだけでコンパイルが落ちたのですか?
Kotlinでは?.let { ... }のラムダの最後の式が、そのラムダ全体の戻り値になります。そこにelseの無いif/else-ifチェーンを置くと、コンパイラはそれを値を返す式として解釈しようとしますが、どの分岐にも一致しない場合の値が定義できないため、elseが無いと網羅的(exhaustive)と判定できずコンパイルエラーになります。
Q2文として書いていたつもりが、なぜ式として扱われたのですか?
Kotlinのifは文としても式としても書ける構文で、コンパイラは書き方ではなく置かれた位置で判断します。ラムダブロックの最後の行に置かれた時点で、そのラムダの戻り値として扱われ、値を返す式としての制約が働きます。元のコードはログを出すかログを保存するかというelseの無い分岐で、値を使うつもりは無い書き方でした。
Q3elseを足す以外に直しようはなかったのですか?
elseを足しても直りますが、今回はdays_mask=0のケースをreturn@letで早期returnさせ、後続のif (newSched != existing)を独立した文として切り離しました。2つの条件は『既存設定を守るガード』と『変更を検知して保存する』という別の関心事だったため、if/elseで1つの式にまとめるより2つの文に分けたほうが意図に合っていました。
Q4この修正でビルドは通ったと確認されていますか?
根拠のコミットメッセージに、gradle 8.9 + JDK21でのassembleDebug成功とAPK容量(7,570,934B)の記録があります。ただしこれは目視のビルド確認であり、この分岐自体を検証する自動テストが追加された記述は根拠にありません。
確認した環境
- Kotlin 1.9.24(org.jetbrains.kotlin.android)
- Gradle wrapper 8.7(コミットメッセージ記載のビルド検証は gradle 8.9 + JDK21)
- 2026-06-17 のコミットでコンパイルエラーを検出・同日修正
この記事の根拠
- Kotlinファイル 251〜271行目コミット 53aacf0
- Kotlinファイル 251〜274行目コミット 7cb86a3
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。