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.
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.

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/actualfor 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.
Related
- Every expect declaration is a promise you made twice — what the completeness guarantee costs
- commonMain first, and the platform only when it earns it — where shared code should start
- CompositionLocal is not dependency injection — the same category error, in Compose
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".