Dan Tech Academy

expect/actual or interface: platform-specific code in Kotlin Multiplatform

When to use expect/actual and when a plain interface with an injected implementation is enough. Android and iOS code, a unit test, and the Beta status of expect classes on Kotlin 2.4.

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:

  1. expect/actual: declare it in commonMain, and each platform writes its own actual.
  2. A plain Kotlin interface in commonMain, 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 actual needs to inherit from an existing platform class.
  • You want to point straight at an existing type with actual typealias, for example actual typealias MyDate = java.time.LocalDate on the JVM.
  • You're writing a framework and need a shared base class for users to extend.

Which one to use

SituationUse
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 testsinterface + pass the implementation in
The actual must inherit a platform class, or point at an existing type with typealiasexpect 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.

Reading is fun. A roadmap gets you there.

The Kotlin Android Roadmap sorts the Android Mastery, OOP and Design Patterns posts into 5 levels - from your first line of Kotlin to an app on the store.

Plus a new post every week. Unsubscribe anytime.