Every Android codebase has the same small shape repeated hundreds of times. You call something that returns a Boolean, you do one thing if it was true, maybe another if it was false, and then you carry on with the flag. It is never a big decision, but it is almost always four to six lines of braces around a one-line idea.
Kotlin 2.5.0-Beta2 introduces three experimental standard library functions aimed squarely at that shape: onTrue(), onFalse() and ifOrNull(). They are small, and small APIs deserve the most scrutiny, because they get used everywhere and misused quietly.
This post covers what the functions do, where they genuinely improve Android code, and the failure modes I would want a teammate to warn me about before I merge a pull request.
The three functions in one minute
fun Boolean.onTrue(action: () -> Unit): Boolean fun Boolean.onFalse(action: () -> Unit): Boolean fun <T> ifOrNull(condition: Boolean, action: () -> T): T? These signatures are simplified. The release notes describe behavior rather than listing modifiers, so check the standard library API reference for details such as inline, and for what that implies for non-local returns and suspend calls inside the lambda.
-
onTruerunsactionwhen the receiver istrue, then returns the receiver unchanged. -
onFalserunsactionwhen the receiver isfalse, then returns the receiver unchanged. -
ifOrNullrunsactionand returns its result when the condition istrue. Otherwise it returnsnulland never touches the lambda.
The first two are “tap” operators, the Boolean cousins of also and onEach. The third is a conditional expression that defaults its else branch to null.
Opting in without leaking the experiment
Per-declaration opt-in is the safest default:
@OptIn(ExperimentalStdlibApi::class) internal fun trackFirstVisit(seen: MutableSet<String>, screen: String): Boolean = seen.add(screen).onTrue { analytics.log("first_visit", screen) } A module-wide opt-in is convenient but widens the blast radius:
kotlin { sourceSets.all { languageSettings.optIn("kotlin.ExperimentalStdlibApi") } } My rule: use the annotation, keep it on internal or private declarations, and keep these functions out of the public API of a library module. Once an experimental call leaks into a public signature or you propagate the opt-in requirement, every consumer inherits the experiment.
Where they earn their place
1. Deduplicated events with Set.add
MutableSet.add returns true only when the element was new. That is exactly the "do this once" primitive analytics code wants.
Before:
fun onScreenShown(screen: String) { if (shownScreens.add(screen)) { tracker.screenViewed(screen) } } After:
@OptIn(ExperimentalStdlibApi::class) fun onScreenShown(screen: String) { shownScreens.add(screen).onTrue { tracker.screenViewed(screen) } } This is a wash in line count, so I would not rewrite working code for it. The version starts to pay off when the flag keeps flowing:
@OptIn(ExperimentalStdlibApi::class) fun markSeen(id: String): Boolean = seenIds.add(id) .onTrue { metrics.increment("unique_items") } .onFalse { metrics.increment("duplicate_items") } Both counters update and the caller still gets the flag. In classic Kotlin that needs a local val, an if/else, and an explicit return.
2. Optional values without a dangling else null
Here is the pattern that shows up in every settings screen:
val badgeLabel: String? = if (unreadCount > 0) "$unreadCount unread" else null With the new function:
@OptIn(ExperimentalStdlibApi::class) val badgeLabel: String? = ifOrNull(unreadCount > 0) { "$unreadCount unread" } The gain is small for a one-liner. It gets more convincing when the lambda body is the interesting part and the else null is noise at the end of a multi-line expression:
import kotlin.time.Duration import kotlin.time.Duration.Companion.days @OptIn(ExperimentalStdlibApi::class) fun renewalBanner(plan: Plan, timeLeft: Duration): BannerState? = ifOrNull(plan.isTrial && timeLeft < 3.days) { BannerState( title = "Trial ends soon", action = BannerAction.Upgrade(plan.id), ) } 3. Lazy evaluation as a feature
ifOrNull only invokes its lambda when the condition is true. That matters when the value is expensive or has a cost:
@OptIn(ExperimentalStdlibApi::class) val thumbnail: Bitmap? = ifOrNull(prefs.showPreviews) { decoder.decode(uri) } An eager alternative that computes the value first and then picks one would waste work on every call. A plain if has the same laziness, so this is about readability, not performance.
Real-world failure modes
This is the part worth reading twice. None of these are compiler errors. They are reviews you will have in six months.
Failure 1: false does not always mean failure
onFalse reads like "on failure". Many Boolean-returning platform APIs do not follow that convention.
@OptIn(ExperimentalStdlibApi::class) fun ensureCacheDir(dir: File) { dir.mkdirs().onFalse { log.error("Could not create cache dir") } // misleading } File.mkdirs() returns false when the directory already exists. On every launch after the first, this logs a scary error for a perfectly healthy state. The fix is to ask the question you mean:
fun ensureCacheDir(dir: File) { dir.mkdirs() check(dir.isDirectory) { "Cache dir unavailable: $dir" } } The same trap exists for Set.remove, Collection.add on lists that allow duplicates, Uri helpers and many legacy SDK calls. Before you attach meaning to false, read what that specific API promises.
Failure 2: Side effects in Compose
It is tempting to write this inside a composable:
@OptIn(ExperimentalStdlibApi::class) @Composable fun CheckoutScreen(state: CheckoutState) { state.isPaid.onTrue { analytics.log("checkout_paid") } // runs on recomposition // ... } Composition can run many times and can be discarded, so this fires an unbounded number of events. A scoped effect is the correct home:
@Composable fun CheckoutScreen(state: CheckoutState) { LaunchedEffect(state.isPaid) { if (state.isPaid) analytics.log("checkout_paid") } } One more subtlety: LaunchedEffect(state.isPaid) runs again if the screen leaves and re-enters composition while isPaid is still true, for example after back navigation. For a strictly once-only payment event, fire it from the ViewModel at the moment the state transitions, not from the UI.
The helpers make a side effect look like a harmless expression, which is precisely why they are dangerous where side effects must be controlled.
Failure 3: A leaked lock
@OptIn(ExperimentalStdlibApi::class) fun refreshOnce() { refreshLock.tryLock().onTrue { repository.refresh() // throws? the lock stays held refreshLock.unlock() } } If refresh() throws, unlock() never runs and every later call silently skips work. The helper adds no cleanup of its own. You could nest a try/finally inside the lambda, but then the lifecycle is buried in a chain that reads like a tap. Keep resource handling in a plain if, where the acquire and release stay visible:
fun refreshOnce() { if (!refreshLock.tryLock()) return try { repository.refresh() } finally { refreshLock.unlock() } } Failure 4: Ambiguous null from ifOrNull
@OptIn(ExperimentalStdlibApi::class) val nickname: String? = ifOrNull(user.hasProfile) { user.profile.nickname } // String? If nickname itself is nullable, the result is null both when the condition was false and when the block returned null. The caller cannot tell the two apart. When "absent" and "present but empty" mean different things, use a type that says so:
sealed interface Nickname { data object NoProfile : Nickname data class Present(val value: String?) : Nickname } Failure 5: Smart casts do not travel into the lambda
@OptIn(ExperimentalStdlibApi::class) fun label(user: User?): String? = ifOrNull(user != null) { user.name } // error: user is still nullable The compiler narrows types based on if, when and &&, not on a Boolean passed as an argument. If narrowing is the point, use the language constructs, or reach for ?.let on the value itself:
fun label(user: User?): String? = user?.name Failure 6: Ignoring that onTrue returns the Boolean
@OptIn(ExperimentalStdlibApi::class) fun saveDraft(draft: Draft): Unit = store.put(draft).onTrue { toast("Saved") } // type mismatch: Boolean, not Unit An expression-bodied function that should return Unit suddenly returns Boolean. Use a block body, or make the intent explicit. This is a compile error, so it is the friendliest failure on the list, but it is still a sign you may be bending the helper to fit a statement.
A short style guide for your team
- Use
onTrueandonFalsewhen the Boolean keeps flowing. If nothing consumes the returned flag, a plainifcommunicates the same thing with less indirection. - Use
ifOrNullwhennullgenuinely means absent. If the block can returnnull, orelseshould carry a real value, write theifexpression. - Keep resource handling and control flow out of the chain. Lock/unlock,
use, cancellation and early returns are easier to audit in a normalif, even if the lambda could technically hold them. - Keep side effects out of composition. Use
LaunchedEffect,SideEffector a ViewModel event instead. - Name the thing you are asking. Prefer
isDirectoryover interpretingfalsefrommkdirs(). - Opt in narrowly and track the experiment. Add a lint or code-search reminder so you revisit these call sites on each Kotlin upgrade.
🔚 Closing thoughts
These helpers do not add expressive power. Everything they do is already possible with if, also and ?.let. What they change is the cost of writing the tiny, common cases cleanly, and that is a worthwhile thing for a standard library to do.
The risk is the opposite one: a method chain that looks tidy and hides a lifecycle, a threading rule, or the difference between “absent” and “empty”. Adopt them where they remove noise around a single idea, and keep the plain if for anything where control flow is the point.
🍎 Share Your Kotlin Code with iOS — Without Rewriting Your App
If “we’ll just build it twice, once per platform” describes how your team ships mobile, I’d recommend “Kotlin Multiplatform for Android Developers” — a 22-chapter, hands-on guide that goes past the sample-project demos and follows the shared module all the way to two app store submissions. No iOS or Swift background required.
It walks through the expect/actual mechanism and source set hierarchy, shared architecture, networking with Ktor, persistence with Room KMP and SQLDelight, Koin dependency injection, Flow and StateFlow with Swift interop (SKIE and Swift Export), Compose Multiplatform versus native SwiftUI, navigation, testing across two runtimes, dual-toolchain CI/CD with Linux and macOS runners, publishing a KMP library, and migrating an existing Android app module by module. Everything builds toward one capstone: an offline-first Expense/Budget Tracker, complete with a pending-sync queue, retry/backoff, and a deployment checklist for both stores.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.







