Engineering

expect/actual is not an interface, and the difference shows up at link time

An interface is resolved at runtime by an instance. expect/actual is resolved at compile time by the source set.

MobileByteSensei 8 min read
kmpkotlinarchitecture

TL;DR

expect/actual looks like an interface and is not one. An interface is satisfied at runtime by an instance you pass in. An expect declaration is satisfied at compile time by the source set being compiled.

That difference decides three things people get wrong: you cannot have two actuals and choose between them, you cannot mock an expect the way you mock an interface, and a missing actual is a build failure rather than a null.

Reach for expect/actual when the answer is fixed per platform. Reach for an interface when the answer varies per call site — which, in practice, is most of the time.

The case this post is about

A Clock for a shared feature. Android uses System.currentTimeMillis(), iOS uses NSDate.timeIntervalSince1970, and tests need a clock they control.

The obvious first version:

// commonMain
expect object Clock {
    fun nowMillis(): Long
}

// androidMain
actual object Clock {
    actual fun nowMillis(): Long = System.currentTimeMillis()
}

// iosMain
actual object Clock {
    actual fun nowMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
}

It compiles on both targets. It is also now impossible to test, and the reason is not a missing feature — it is that this construct was never a seam.

The symptom

The first thing you try is the thing you would do with an interface:

// commonTest
@Test
fun expiresAfterSevenDays() {
    val clock = FakeClock(startMillis = 0)   // ← there is nothing to pass it to
    val subject = TokenStore(clock)
    …
}

There is no constructor parameter, because Clock is not a value. It is a name the compiler resolves to whichever actual exists in the source set currently being compiled. In commonTest, that is the commonTest compilation — which has no actual for it at all.

The second attempt is worse, because it compiles:

// commonTest — add a test-only actual?
actual object Clock {                      // error, or worse: a second actual
    actual fun nowMillis(): Long = 0L
}

You are not injecting a fake. You are trying to declare a second implementation of a declaration that is defined to have exactly one per target. The build tells you so, in the vocabulary of linking rather than of dependency injection — which is the first clue that this is not the tool you wanted.

Why the obvious fix failed

"Make the expect an interface instead." Right instinct, and it is the eventual answer — but on its own it moves the problem rather than solving it. Something still has to produce the platform instance, and that producer is where expect/actual genuinely belongs.

"Add an actual in commonTest." commonTest is not a target. Actuals live in platform source sets, and the test compilation for androidUnitTest already has the Android actual in scope. You cannot shadow it.

"Use a var and overwrite it in tests." This works and is how a lot of real code ends up:

expect object Clock { fun nowMillis(): Long }
object TestOverrides { var clock: (() -> Long)? = null }   // global mutable state

It is global mutable state with test ordering hazards, and it exists only because the first construct could not be injected. The fix for an un-injectable dependency is rarely another global.

"Expect a class with a constructor." Closer — expect class can take constructor parameters — but the set of implementations is still fixed at one per target. You still cannot have a fake and the real one in the same compilation, which is the entire requirement.

The actual mechanism

Two different resolutions, which is the whole post:

An interface is resolved at runtime, by an instance. The compiler knows only the contract. Any number of implementations can exist simultaneously, and which one runs is decided by whoever constructs the object. That is what makes substitution possible — a fake and a real one are peers.

An expect is resolved at compile time, by the source set. The compiler matches the declaration to exactly one actual in the platform source set being compiled, and emits a direct reference to it. There is no indirection at runtime, no vtable, no instance to swap. The call site is bound before the program ever runs.

An interface resolved at runtime, where SystemClock, FakeClock and PreviewClock coexist as peers and the caller picks one, beside an expect declaration resolved at compile time, binding to exactly one actual per target with no instance to substitute

Three consequences follow mechanically:

Exactly one actual per target, and it is mandatory. Forget the iOS actual and you do not get a null or an UnsupportedOperationException — you get a build failure naming the unmatched declaration. That is a genuine strength, and it is why expect/actual is the right tool for things that must exist per platform: the compiler enforces completeness in a way no interface can.

No substitution. Because there is no instance, there is nothing to pass. "Mocking an expect" is not a missing library feature; it is a category error.

Signatures must match structurally, not just nominally. Default arguments, for instance, live on the expect and not on the actuals — a rule that only makes sense once you see that the expect is the single source the compiler reads.

The rule that falls out:

expect/actual for what is fixed per platform. An interface for what varies per call site. Tests vary per call site. So do fakes, and so does anything you want to swap in a flavour or a preview.

One more resolution worth knowing about, because it is the escape hatch people reach for and it has a cost: actual typealias. When a platform already has the type you want, you can satisfy the expect by aliasing to it rather than writing a wrapper:

// commonMain
expect class PlatformFile

// androidMain
actual typealias PlatformFile = java.io.File        // zero wrapping, zero overhead

Convenient, and it changes what PlatformFile is in a way that leaks. An alias is not a new type — it is the same type under another name — so on Android every member of java.io.File is reachable through it. Common code cannot see them, but platform code can, and the moment androidMain starts calling file.toPath() through the alias, the abstraction exists in name only on that target while iosMain is working with something structurally different.

The second consequence is harder to back out of: you cannot add members to an aliased expect class. Anything the common layer needs has to already exist, with a matching signature, on every aliased type — so the first platform that lacks it forces either a wrapper class after all, or an extension function that quietly does not apply uniformly.

Alias when the platform type genuinely is the abstraction. Wrap when you want the freedom to change your mind.

The fix

Keep both. The interface is the seam, and expect/actual produces the platform instance:

// commonMain — the seam. Resolved at runtime, by whoever constructs the subject.
interface Clock {
    fun nowMillis(): Long
}

// commonMain — the producer. Resolved at compile time, one per target, mandatory.
expect fun systemClock(): Clock

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

// iosMain
actual fun systemClock(): Clock = object : Clock {
    override fun nowMillis() = (NSDate().timeIntervalSince1970 * 1000).toLong()
}

The subject now takes the seam, and the default is the platform one:

class TokenStore(private val clock: Clock = systemClock()) {
    fun isExpired(issuedAt: Long) = clock.nowMillis() - issuedAt > SEVEN_DAYS
}

And the test needs no platform machinery at all — it is an ordinary object in commonTest:

class FakeClock(var millis: Long) : Clock {
    override fun nowMillis() = millis
}

@Test
fun expiresAfterSevenDays() {
    val clock = FakeClock(0)
    val store = TokenStore(clock)
    clock.millis = SEVEN_DAYS + 1
    assertTrue(store.isExpired(issuedAt = 0))
}

Two properties worth naming:

The expect shrank to a factory. It is now one line per target whose only job is to construct. Platform code that was spread through the type is concentrated in the place the compiler already forces you to complete.

The mandatory-ness is still working for you. Add a wasmJs target and the build fails until systemClock() has an actual there. That is the property worth keeping expect/actual for, and the refactor preserves it exactly.

How we proved it

The test above is the proof that substitution works, but the more interesting check is the one that proves the other half — that completeness is still enforced.

Add a target without an actual and the build stops:

e: Expected function 'systemClock' has no actual declaration in module
   <shared> for Compilation 'main' of target 'wasmJs'

A link-time error, naming the declaration and the target. Compare it to the interface-only design, where a missing platform implementation is a runtime NotImplementedError on whichever screen happens to call it first — if that screen is reached at all in your smoke test.

That contrast is the argument for using both: the interface buys substitution, the expect buys a compiler that will not let you ship a platform half-done.

One honest limit. This pattern costs an extra declaration per dependency, and on a small shared module with no tests, the single expect object really is simpler. The refactor pays for itself the first time something needs a fake — which, for anything with logic in it, is immediately.

What this generalizes to

Ask when the choice is made. Compile time or runtime is not an implementation detail; it decides what you can substitute, what the compiler can check for you, and what a missing piece looks like. Most "how do I mock X in KMP" questions are this question, unasked.

A construct that enforces completeness cannot also offer substitution. They are the same property seen from two sides: exactly-one is what the compiler guarantees, and exactly-one is what makes a fake impossible. Wanting both from one mechanism is wanting a contradiction, and the answer is to use two mechanisms.

Push expect/actual to the leaves. The more behaviour sits inside it, the more behaviour is untestable in commonTest — and commonTest is where multiplatform pays off. A factory is a leaf; a whole expect class with logic is not.

Read the error's vocabulary. "Has no actual declaration" is a linker talking, not a container. When a compiler describes your dependency problem in terms of symbols and targets, it is telling you that you reached for a static mechanism to solve a dynamic problem.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS