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.

kotlin-boolean-helpers-overview

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.

  • onTrue runs action when the receiver is true, then returns the receiver unchanged.
  • onFalse runs action when the receiver is false, then returns the receiver unchanged.
  • ifOrNull runs action and returns its result when the condition is true. Otherwise it returns null and 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.

kotlin-boolean-helpers-data-flow

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.

kotlin-boolean-helpers-decision-guide

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.

kotlin-boolean-helpers-failure-modes

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

  1. Use onTrue and onFalse when the Boolean keeps flowing. If nothing consumes the returned flag, a plain if communicates the same thing with less indirection.
  2. Use ifOrNull when null genuinely means absent. If the block can return null, or else should carry a real value, write the if expression.
  3. Keep resource handling and control flow out of the chain. Lock/unlock, use, cancellation and early returns are easier to audit in a normal if, even if the lambda could technically hold them.
  4. Keep side effects out of composition. Use LaunchedEffect, SideEffect or a ViewModel event instead.
  5. Name the thing you are asking. Prefer isDirectory over interpreting false from mkdirs().
  6. 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.