DEV Community

Cover image for expect/actual is the wrong default in KMP
Siddharth Pandalai
Siddharth Pandalai

Posted on

expect/actual is the wrong default in KMP

Every Kotlin Multiplatform tutorial teaches expect/actual in the first ten minutes. It is the headline feature, the thing that makes KMP feel like magic. So new teams learn it first and then reach for it every single time common code needs to touch a platform.

That habit is how shared modules turn into a knot. expect/actual is a fine tool for a narrow job, and the wrong default for most of them.

The Understudy, drawn as a specimen plate. Cast by name at compile time. No swapping mid-run. Labelled THE UNDERSTUDY, actual.

What expect/actual actually is

The alternative: An interface in common, injected per platform

It is a compile-time binding by name. You declare something in common:

// commonMain
expect class Clipboard() {
    fun copy(text: String)
}
Enter fullscreen mode Exit fullscreen mode

and the compiler requires exactly one actual per platform:

// androidMain
actual class Clipboard actual constructor() {
    actual fun copy(text: String) { /* android impl */ }
}
Enter fullscreen mode Exit fullscreen mode

The binding is fixed at build time. There is one actual for Android, one for iOS, and the compiler wires them in. That sounds convenient, and for the right case it is.

Where it hurts

How to choose: Weld, or hinge?

The moment you want to do anything other than "one fixed body per platform," the weld gets in the way.

  • You cannot inject a fake. There is no test actual, so your common use case that depends on Clipboard cannot be tested in commonTest without inventing a whole extra source set to satisfy the compiler.
  • You cannot hold two implementations. No real one and a fake one, no A/B, no decorator. The compiler picks one per platform and that is that.
  • Your common code is coupled to platform declarations, one weld per concern, spread across storage, networking, clipboard, analytics, and everything else you reached for it with.

I lived this on PaymentsLab. Early common code used expect/actual for every platform seam. It compiled and shipped. Then I sat down to unit-test a payment use case in commonTest and simply could not, because the seams had no fakes.

The better default: an interface, injected

// commonMain
interface Clipboard {
    fun copy(text: String)
}

class CopyReceiptUseCase(private val clipboard: Clipboard) {
    fun run(receipt: Receipt) = clipboard.copy(receipt.asText())
}
Enter fullscreen mode Exit fullscreen mode

Provide the real thing per platform, and hand it in through your DI graph (Koin, in our case):

// androidMain
class AndroidClipboard(private val ctx: Context) : Clipboard { /* ... */ }
Enter fullscreen mode Exit fullscreen mode
// commonTest
class FakeClipboard : Clipboard {
    var last: String? = null
    override fun copy(text: String) { last = text }
}

@Test fun copies_receipt() {
    val fake = FakeClipboard()
    CopyReceiptUseCase(fake).run(receipt)
    assertEquals(expected, fake.last)
}
Enter fullscreen mode Exit fullscreen mode

Same platform bodies. Same behavior. But now the common code depends on an interface it owns, not on a platform declaration, and it is testable in one line.

So when is expect/actual right?

For the small, fixed, one-per-platform joints where a stand-in makes no sense:

  • A typealias to a platform type (expect class Uuid, actualized to the platform's UUID).
  • A single top-level function with exactly one meaning per platform and nothing to test, like reading a platform constant.

Those are genuine welds. One joint, permanent, no reason to swap it.

The takeaway

The payload: Reach for the tool that keeps your options open

The KMP question is rarely "how do I reach the platform." It is "what do I hand my common code." Hand it an interface it owns, and inject the platform body. Keep expect/actual for the handful of places you actually want a weld.

The Understudy is perfect for a small fixed role, cast by name, on stage every night. Just do not cast one for every part in the play.


๐ŸŒ€ Iteration 5 of **The Loopdown. field notes from an engineer who writes.
Series: **One Brain Two Bodies* ยท Featuring: The Understudy*
๐Ÿ“š The full series: One Brain Two Bodies

Follow the loop โ†’ LinkedIn ยท dev.to ยท GitHub

Top comments (0)