The problem
In a Kotlin Multiplatform (KMP) app, most of the logic lives in commonMain: ViewModels, repositories, game rules, data formatting. Sooner or later you need something only the platform has, such as the system clock, the clipboard, or an Android Context.
Kotlin gives you two ways to reach those from shared code:
expect/actual: declare it incommonMain, and each platform writes its ownactual.- A plain Kotlin
interfaceincommonMain, a class per platform that implements it, and the implementation passed in from outside.
Both work. This post helps you pick the right one for each spot, based on the official Kotlin docs and code I built on Kotlin 2.4.
How expect/actual works
You declare a function with expect in commonMain, with no body:
// commonMain/kotlin/com/example/match/Now.kt
package com.example.match
expect fun currentTimeMillis(): Long
Each platform source set writes the actual, in the same package:
// androidMain/kotlin/com/example/match/Now.android.kt
package com.example.match
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
// iosMain/kotlin/com/example/match/Now.ios.kt
package com.example.match
import platform.Foundation.NSDate
import platform.Foundation.timeIntervalSince1970
actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
When it builds each target, the compiler merges the expect with the matching actual into one declaration. It also checks three things for you: every expect has an actual on every platform you build, the expect side has no implementation, and both sides share a package. Forget the iOS actual and the iOS build fails right away.
This fits small things with no state: reading the clock, choosing a CoroutineDispatcher for IO, getting the platform name.
Where expect fun gets awkward
Say you want to copy a match result to the clipboard. On Android that needs a Context. commonMain has no idea what a Context is, so you can't write expect fun copy(context: Context, text: String).
You could keep a Context in a global variable in androidMain, but that is hidden state: tests get harder, and shared code depends on something it can't see.
An interface is much simpler here:
// commonMain/kotlin/com/example/match/Clipboard.kt
package com.example.match
interface Clipboard {
fun copy(text: String)
}
class ShareResult(private val clipboard: Clipboard) {
fun copyWinner(winner: String) {
clipboard.copy("Winner: $winner")
}
}
Each platform implements the interface with its own API. The Android version takes a Context in its constructor:
// androidMain/kotlin/com/example/match/AndroidClipboard.kt
package com.example.match
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
class AndroidClipboard(private val context: Context) : Clipboard {
override fun copy(text: String) {
val manager = context.getSystemService(ClipboardManager::class.java)
manager.setPrimaryClip(ClipData.newPlainText("match result", text))
}
}
// iosMain/kotlin/com/example/match/IosClipboard.kt
package com.example.match
import platform.UIKit.UIPasteboard
class IosClipboard : Clipboard {
override fun copy(text: String) {
UIPasteboard.generalPasteboard.string = text
}
}
The objects are created in platform code, where that information exists:
// Android, for example inside an Activity
val shareResult = ShareResult(AndroidClipboard(applicationContext))
// iOS
val shareResult = ShareResult(IosClipboard())
If the project already uses a DI framework such as Koin, register each platform's implementation in a DI module instead of those two lines. The Kotlin docs suggest the same thing: when a dependency is an interface, expect/actual is only needed in the DI configuration, if at all.
Interfaces are easier to test
commonTest is compiled and run separately for each target. With an expect fun, a test always calls the real actual of the target it runs on, and you can't swap in a fake.
With an interface, you write the fake right in commonTest:
// commonTest/kotlin/com/example/match/ShareResultTest.kt
package com.example.match
import kotlin.test.Test
import kotlin.test.assertEquals
class FakeClipboard : Clipboard {
val copied = mutableListOf<String>()
override fun copy(text: String) {
copied += text
}
}
class ShareResultTest {
@Test
fun copiesTheWinnerLine() {
val clipboard = FakeClipboard()
ShareResult(clipboard).copyWinner("X")
assertEquals(listOf("Winner: X"), clipboard.copied)
}
}
This test needs no emulator, never touches the real clipboard, and behaves the same on every target.
What about expect class?
You can also write expect class, expect object or expect interface. According to the current Kotlin docs, expected and actual classes are still in Beta. I built a sample on Kotlin 2.4.10 (the latest release at the time of writing is 2.4.21), and the compiler still prints this warning:
'expect'/'actual' classes (including interfaces, objects, annotations, enums, and 'actual' typealiases) are in Beta. Consider using the '-Xexpect-actual-classes' flag to suppress this warning.
If you've thought it through and still want them, add this flag in build.gradle.kts to silence the warning:
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
The docs advise against expect class in simple cases an interface can handle, for the same reasons as above: an interface allows several implementations on one platform and is easy to replace with a fake in tests. An expect class earns its place in a few specific cases:
- The
actualneeds to inherit from an existing platform class. - You want to point straight at an existing type with
actual typealias, for exampleactual typealias MyDate = java.time.LocalDateon the JVM. - You're writing a framework and need a shared base class for users to extend.
Which one to use
| Situation | Use |
|---|---|
| A small function or value with no state that tests don't need to replace (clock, dispatcher, platform name) | expect fun / expect val |
Needs a platform dependency such as Context, holds state, or needs a fake in tests | interface + pass the implementation in |
The actual must inherit a platform class, or point at an existing type with typealias | expect class (accept Beta) |
When in doubt, start with an interface. The Kotlin docs say it plainly: rely on standard language constructs wherever possible, and use expect/actual only when you need them.
Keep learning
In Kotlin Mobile 101 you read and change a complete Kotlin Multiplatform app. It uses expect/actual in some places and interfaces in others, with a unit test suite so you can check each change you make.