Skip to content
Go back

The Same System: Advanced expect/actual in KMP

by KMP Bits

KMP Bits Cover

Normally I’d reach for the paddock here. But this month the whole planet is watching the same tournament, so just this once I’m swapping the pit lane for the pitch. Back to the box next time.

Watch a World Cup and you notice something. Almost every side lines up in a version of the same shape. Four at the back, three in midfield, three up top. The system is shared. What changes is who fills each slot. One nation puts a converted winger at left back, another puts a career defender there, a third improvises with a kid who has played the position for six months. The position on the tactics board is fixed. The player wearing that number is a local decision.

That is expect/actual, and it is the closest thing KMP has to a signature move. You declare the position once in commonMain. Each platform fields its own player. The trivial version, an expect fun with a one-line actual on each side, gets shown in every intro tutorial and it is genuinely easy. This article is about what happens after that, when you start declaring classes, constructors, and typealiases, and the compiler starts enforcing rules that the tutorials never mention.


The position and the player

Here’s the shape everyone already knows, just so we’re speaking the same language. The expect declaration lives in commonMain and has no body. It is the role, not the person.

// commonMain
expect fun homeGround(): String

Then each source set fields its player:

// androidMain
actual fun homeGround(): String = "Android ${Build.VERSION.SDK_INT}"

// iosMain
actual fun homeGround(): String =
    "iOS ${UIDevice.currentDevice.systemVersion}"

The compiler’s job is to make sure every position on the board has exactly one player in every squad. Miss an actual in iosMain and iOS doesn’t compile. There is no “we’ll sort the left back out later.” Every slot, every platform, before the whistle. That strictness is the whole point, and it is also where most of the surprises come from once the declarations get more ambitious than a function.


Promoting the position to a class

Functions are fine for a single piece of behaviour. The moment you want shared state, a lifecycle, or a handful of related methods behind one common type, you reach for expect class. This is where the profile gets detailed. You are no longer saying “someone plays left back,” you are saying “left back who defends, overlaps, and takes throw-ins,” and every squad has to field a player who does all three.

// commonMain
expect class PlatformClock() {
    fun now(): Long
    fun formatted(): String
}
// androidMain
actual class PlatformClock actual constructor() {
    actual fun now(): Long = System.currentTimeMillis()
    actual fun formatted(): String =
        SimpleDateFormat.getInstance().format(Date(now()))
}

Notice actual constructor(). That is the first thing that trips people. On an expect class, the constructor is part of the contract, and the actual side has to declare it as an actual constructor, not just inherit it silently. If the expected class exposes a constructor, the player you field has to be callable exactly the same way from commonMain. The role defines how you call a player up. The player doesn’t get to change the phone number.

There is a second thing about expect class, and it is easy to miss because your code compiles anyway. You get a warning: expect/actual classes are still in Beta. To silence it you add the -Xexpect-actual-classes compiler flag. I don’t love shipping a language feature that announces its own provisional status every time I build, and it is one of the reasons I treat expect class as a tool of last resort rather than a default. More on that at the end.


Fielding a player who already plays in that league

Here is the pattern that took me embarrassingly long to properly appreciate. You don’t always have to write a brand new actual class. If the platform already has a type that does exactly what your expect describes, you can field that one directly with an actual typealias.

// commonMain
expect class Uuid {
    override fun toString(): String
}

fun newId(): Uuid = randomUuid()
expect fun randomUuid(): Uuid
// androidMain
actual typealias Uuid = java.util.UUID

actual fun randomUuid(): Uuid = java.util.UUID.randomUUID()

No wrapper, no delegation, no hand-written class forwarding calls to the real thing. On Android, Uuid literally is java.util.UUID. It is the naturalised player who already turns out for that league every week. You are not training a new one, you are calling up someone the domestic season already produced.

The catch is exactly the catch you’d expect from that analogy. The player has to already do the job, precisely. The aliased type must already have every member your expect declares, with matching signatures. You cannot bolt an extra method onto it through the typealias. If java.util.UUID were missing something your common code needs, the alias breaks and you are back to writing a real actual class that wraps it. So actual typealias is the cleanest option when it fits, and it simply doesn’t stretch when it doesn’t. Know which situation you’re in before you commit to it.


The rules of the convocation

This is the section I wish someone had handed me on day one, because every item here is a compiler error or a warning I’ve actually hit, and none of them are obvious. Think of the compiler as the VAR booth. It is not there to help you attack. It is there to disallow the things that break the rules, coldly and without explanation beyond a red line.

Default values live on the position, not the player. If a parameter has a default, it goes on the expect declaration only. The actual must not repeat it.

// commonMain
expect fun teamSheet(captain: String = "the usual skipper"): String

// androidMain, correct
actual fun teamSheet(captain: String): String = "Captain: $captain"

// androidMain, compile error: an actual must not specify default values
actual fun teamSheet(captain: String = "the usual skipper"): String = "Captain: $captain"

The coach sets the standing instruction once. The player doesn’t get to redefine it in every squad, or you’d have eleven slightly different versions of the same order.

Visibility has to line up. The actual can’t be more restrictive than the expect. Declare the position public and then try to field a private actual, and the compiler stops you. Since Kotlin 2.0 the actual is allowed to be more permissive than the expect, but never more restrictive.

Every position, every squad, no exceptions. One expect needs exactly one actual per platform source set. Not zero, not two. This is the one people meet first and it is the least surprising: leave a slot empty and that platform doesn’t build.

Enums match entry for entry. You can declare an expect enum class, but the actual has to carry the identical set of entries. You cannot sneak a platform-only entry onto the iOS side. If Android and iOS genuinely need different cases, expect/actual on an enum is the wrong tool, and I’d model the difference explicitly instead of trying to force one enum to be two things.

None of these are bugs. They are the eligibility rules that keep the shared system actually shared. The reason a common caller can trust that PlatformClock behaves the same everywhere is precisely that the compiler refuses to let the two sides drift.


When not to field a fixed player at all

Now the opinion, because I’ve watched expect class get reached for far too early, mine included. Fixing a specific player into a position is the least flexible thing you can do. It is a starting eleven with no bench.

A lot of the time what you actually want is a role that anyone can fill, decided at runtime. That is an interface in commonMain, with the platform providing an implementation through a plain expect fun factory or, better, through dependency injection.

// commonMain
interface Clock {
    fun now(): Long
}

expect fun systemClock(): Clock
// androidMain
actual fun systemClock(): Clock = object : Clock {
    override fun now() = System.currentTimeMillis()
}

This looks like more ceremony than an expect class, and for a two-method type it slightly is. What you buy is a bench. You can swap in a fake Clock in commonTest without touching platform code, you sidestep the Beta warning entirely, and your common code depends on an abstraction instead of a concrete platform-shaped class. The substitution is available when you need it.

So my rule is narrow. Reach for expect fun for single behaviours. Reach for an interface plus a factory or DI when you want something swappable, which is most of the time. Reserve expect class for the genuine case where a shared common type must carry state and a lifecycle and there is no clean seam to put an interface across. It exists for a reason, but it is the specialist you bring on for one job, not the eleven you name every week.


The line I hold

expect/actual is the same system running under every squad in the tournament. The shape is declared once in commonMain and every platform fields its own player into every slot, and the compiler is the officiant that refuses to let the two sides play different games. The easy part is the expect fun. The part that earns you a clean codebase is knowing that actual typealias fields a player the league already produced, that constructors and default values and visibility all sit on the position rather than the person, and that most of the time the flexible move is a role anyone can fill rather than one name pinned to the board.

Pick the smallest declaration that does the job. Let the compiler enforce the eligibility rules instead of fighting them. And keep expect class on the bench until the match actually calls for it.

Right, that’s enough football. The lights go back on over the pit lane next week. 🏁

The KMP Bits app is available on App Store and Google Play — built entirely with KMP.


Cover image prompt

Cool slate-blue and steel-grey background, near-dark with a subtle cold sheen. A horizontal editorial-style cover image for a developer blog post. The main visual is a close-up of a football tactics board: a dark tactical whiteboard showing a team formation with magnetic player markers arranged in a 4-3-3 shape, crisp white positional lines and arrows drawn across it, a coach's hand in the frame adjusting one marker into its slot. The mood is methodical and strategic, a system being set before kickoff. In the background at around 15 percent opacity, a floodlit football stadium pitch at night adds atmosphere without competing with the main image. In the lower third, the text "The Same System" in large, clean white sans-serif. Cold cyan-white accent lighting on the tactics board. Minimal, high contrast. Bottom-right corner: the text "KMP Bits" in a clean modern sans-serif, white, small.

Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Safety Car: Coroutine Exception Handling in KMP