For a decade, every Android codebase written in Kotlin has carried the same quiet tax. You want a factory function, a constant, a TAG, a newInstance() for a Fragment, and the language answers with a nested singleton called Companion. It works, it is idiomatic, and it leaks into places you never planned for: Java call sites, reflection, ProGuard rules, bytecode size.

Kotlin 2.5.0-Beta1 introduces an experimental alternative: companion blocks and companion extensions. JetBrains describes the feature in The Companions to Come, and the full design lives in KEEP-0449. This article is the Android engineer’s view: what changes, what to try first, and, more importantly, how an adoption goes wrong.

Status note. The feature is experimental. Snippets below follow the syntax shown in the announcement, so pin your toolchain and re-check against the KEEP before copying anything into production. Where I am reasoning from the design rather than quoting it, I say so.

1. The problem a companion object solves, and the one it creates

A companion object is a real object. That is its strength (it can implement interfaces, be passed around as a value) and its cost (the compiler must materialize a class and a holder for it).

Here is the pattern most Android modules contain today:

data class Money(val minorUnits: Long, val currency: Currency) { operator fun plus(other: Money): Money { require(currency == other.currency) { "Currency mismatch: $currency vs ${other.currency}" } return copy(minorUnits = minorUnits + other.minorUnits) } companion object { fun zero(currency: Currency) = Money(0L, currency) fun fromMajor(major: BigDecimal, currency: Currency): Money = Money(major.movePointRight(currency.defaultFractionDigits).longValueExact(), currency) } } 

On the JVM this produces a Money class, a nested Money$Companion class, and a static field on Money that holds the singleton. Kotlin callers never notice. Java callers, reflection-based libraries, and shrinker configurations do.

2. What the new syntax looks like

The block form is deliberately a near-copy of the old one. The keyword object disappears:

data class Money(val minorUnits: Long, val currency: Currency) { companion { fun zero(currency: Currency) = Money(0L, currency) fun fromMajor(major: BigDecimal, currency: Currency): Money = Money(major.movePointRight(currency.defaultFractionDigits).longValueExact(), currency) } } 

Call sites in Kotlin are unchanged: Money.zero(usd). What changed is the compilation strategy. According to the announcement, companion blocks compile to static members on platforms like the JVM, instead of members of a hidden singleton class.

companion-bytecode-shapes

Extensions: adding members to types you do not own

The second feature is the one Android developers will notice first. A companion extension attaches a type-level member to any class or interface, including Java classes and types without a companion:

import android.net.Uri companion fun Uri.forRoute(route: String, query: Map<String, String> = emptyMap()): Uri = Uri.Builder() .scheme("shopapp") .authority("app") .appendPath(route) .apply { query.forEach { (k, v) -> appendQueryParameter(k, v) } } .build() // call site val link = Uri.forRoute("cart", mapOf("promo" to "DIWALI")) 

Uri.Builder percent-encodes path segments and query parameters for you, so callers never hand-escape values. A Map parameter also avoids the array allocation a vararg of pairs creates on every call, which matters if routing code runs on a hot path.

Until now the same idea meant a top-level function with an awkward name (uriForRoute(...)) that no one could discover through autocomplete on Uri.. Here the function reads as if the platform class had always shipped it.

3. Opting in, and who you lock out

Two compiler flags control the feature:

  • -Xcompanion-blocks enables blocks.
  • -Xcompanion-blocks-and-extensions enables both blocks and extensions.

In a Gradle build, apply them narrowly, through a convention plugin rather than ad hoc per module:

// build-logic: AndroidAppConventionPlugin.kt (sketch) class AndroidAppConventionPlugin : Plugin<Project> { override fun apply(target: Project) = with(target) { extensions.configure<KotlinAndroidProjectExtension> { compilerOptions { freeCompilerArgs.addAll("-Xcompanion-blocks-and-extensions") } } } } 

The reach of each flag is the part that bites. Per the announcement, turning on extensions makes the compiler emit pre-release binaries, so a library that uses companion extensions cannot be consumed as a dependency until the feature stabilizes. Exposing plain blocks from a library is allowed, but anyone consuming those blocks must also enable the blocks feature.

companion-flags-and-reach

The practical reading: extensions belong in leaf application modules for now. Blocks can reach library code, but only if you control, or can coordinate with, every consumer.

4. Keep, migrate, or extend: a decision path

JetBrains is explicit that nobody has to migrate. Companion objects remain a guaranteed part of the language, and a handful of use cases only they serve. The clearest one: a companion that must implement an interface, because it compiles to a full class.

fun interface ScreenFactory<S : Screen> { fun create(args: Bundle): S } class CheckoutScreen(private val cartId: String) : Screen { // Must stay an object: it is passed around as a ScreenFactory value. companion object : ScreenFactory<CheckoutScreen> { override fun create(args: Bundle) = CheckoutScreen(requireNotNull(args.getString("cartId")) { "cartId missing" }) } } // somewhere in navigation setup registry.register("checkout", CheckoutScreen) // the companion as a value 

A companion { } block has no instance to hand over, so this registration style cannot move. Everything else (constants, factories, newInstance helpers) is a candidate.

companion-decision-path

The announcement describes mechanical migration as deleting the object keyword in most cases, followed by a recompile of the module and everything that depends on it.

5. Failure modes: how this goes wrong in a real app

The syntax migration is trivial. The consequences are not. These are the places I would expect trouble, grouped along the build-to-runtime pipeline. Items marked inferred come from reasoning about the design, not from the announcement, so verify them against your own build.

companion-failure-pipeline

Failure 1: the silent binary break in a published library

The announcement states that the migrated project and everything depending on it must be recompiled. For an app module that is a non-event. For an AAR you publish, it means existing consumers compiled against Money.Companion.zero(...) will fail when they pick up the new artifact. Treat a migration in public API as a binary-breaking change: bump the major version, or leave that library on companion objects.

Failure 2: Java callers that stop compiling (inferred)

If any Java in your codebase, or in a downstream app, calls the member through the singleton accessor, that route is tied to the old shape:

// Before: works because Companion is a real field holding a real object Money zero = Money.Companion.zero(usd); 

Since blocks compile to static members, I expect the Java form to become a plain static call, Money.zero(usd). Search the repository for .Companion. before flipping anything. It also means that @JvmStatic on these members likely becomes redundant. Confirm in the KEEP rather than assuming.

Failure 3: reflection that expects a companion instance

Anything that discovers type-level functionality by asking Kotlin reflection for the companion will find nothing after migration:

import kotlin.reflect.full.companionObjectInstance fun <T : Any> KClass<T>.findFactory(): ScreenFactory<T>? = @Suppress("UNCHECKED_CAST") companionObjectInstance as? ScreenFactory<T> 

For classes converted to blocks, companionObjectInstance returns null, and the code above quietly falls back to whatever default you wrote. Plugin systems, custom DI glue, and serialization helpers are the usual suspects. Grep for companionObject and companionObjectInstance, and write a unit test that asserts the lookup still succeeds for each registered type.

Failure 4: R8 and ProGuard rules written for a class that no longer exists (inferred)

Keep rules aimed at **$Companion classes silently match nothing once the nested class is gone. The build stays green; the failure arrives in release builds as a stripped or renamed member that reflection or JNI still expects. After migrating, diff the R8 usage.txt and seeds.txt outputs for the affected classes, and rewrite the rule against the outer class's static members.

Failure 5: tooling that has not caught up

The announcement is candid here: other ecosystem tools may not support blocks yet, and JetBrains is working with the major players. For an Android project that list is long: annotation processors, KSP-based code generators, lint rules, IDE inspections. Do not discover this on your main branch. Spend a day on a throwaway branch with the flag on and the full CI matrix running.

Failure 6: a flag that leaks into a library

The cheapest way to reproduce the pre-release problem is to enable the extensions flag in a convention plugin that every module shares. A guard in the build prevents it:

// root build.gradle.kts (sketch) import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask subprojects { plugins.withId("maven-publish") { // afterEvaluate so flags added by convention plugins are already visible afterEvaluate { tasks.withType<KotlinCompilationTask<*>>().configureEach { val flags = compilerOptions.freeCompilerArgs.get() check("-Xcompanion-blocks-and-extensions" !in flags) { "$path publishes artifacts; companion extensions would produce pre-release binaries." } } } } } 

Two details matter here. First, the check runs when Gradle configures the compile tasks, so a bad flag fails the build before any compilation starts, rather than midway through execution as a doFirst block would. Second, matching on KotlinCompilationTask<*> instead of the narrower KotlinCompile also covers Android variant and multiplatform compile tasks, which a KotlinCompile-only match can miss depending on your Kotlin Gradle plugin version. Treat the snippet as a starting point and test it against your own AGP and KGP combination, including with the configuration cache on.

Failing at configuration time is far cheaper than failing in a consumer’s sync a week later.

6. A rollout plan I would actually run

  1. Canary branch. Enable -Xcompanion-blocks in one app module, convert a single class, run unit tests, instrumentation tests, and a release build with R8.
  2. Grep before you edit. Search for .Companion., companionObject, and $Companion across Kotlin, Java, and keep-rule files.
  3. Convert leaf utilities first. Constants and pure factories, with no interface implementations and no reflection consumers.
  4. Leave interface-bearing companions alone. They are first-class citizens, not legacy.
  5. Add extensions last, and only in apps. Start with platform types where the readability win is obvious, such as Uri, Bundle, and Intent helpers.
  6. Gate libraries. Add the publishing guard above before any teammate touches a shared convention plugin.

7. Where this leaves Android and Multiplatform

For pure Android work, the benefit is cleaner bytecode and a more natural Java story. For Kotlin Multiplatform, the announcement points to a larger payoff: because blocks compile like platform statics, an expected companion block member can be actualized by a Java class with static members. That removes a long-standing wrinkle at the Android boundary. The KEEP has the exact rules, and I would read it in full before designing a shared API around it.

My position: adopt blocks for new application code once your toolchain passes the canary branch; defer extensions in anything you publish; and never migrate a companion just because you can. The old companion object is not deprecated and not going away. The new form is a sharper tool, not a replacement.

Sources

📘 Master Your Next Technical Interview

Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.

Originally published on Medium.