| name | kmp-expect-actual |
| description | Idiomatic use of `expect`/`actual` declarations in Kotlin Multiplatform, when to prefer interfaces + DI instead, and how to design platform abstractions that age well. Use when you need platform-specific behavior in `commonMain`. |
expect / actual (and when to avoid it)
Instructions
expect/actual is the KMP compiler-level mechanism for providing a platform-specific implementation of a declaration. It is one of several tools; it is often not the best one.
1. Decision tree
Do you need different behavior per platform?
├─ Is the API *exactly* the same shape on every target? ──► expect/actual
├─ Is it platform-specific infra (Context, NSUserDefaults, window)? ──► interface + DI
└─ Is it a value that differs but has no behavior? ──► expect val / BuildKonfig
Prefer an interface with a DI-provided platform implementation. It is testable (mock in commonTest), composable (wrap with decorators), and lives at the app layer, not the compiler layer.
2. expect/actual — the good cases
Small, stateless, truly language-level differences:
expect fun currentTimeMillis(): Long
expect class Uuid {
companion object { fun random(): Uuid }
override fun toString(): String
}
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual class Uuid private constructor(private val v: java.util.UUID) {
actual companion object {
actual fun random(): Uuid = Uuid(java.util.UUID.randomUUID())
}
actual override fun toString(): String = v.toString()
}
import platform.Foundation.NSDate
import platform.Foundation.NSUUID
actual fun currentTimeMillis(): Long =
(NSDate().timeIntervalSince1970 * 1000).toLong()
actual class Uuid private constructor(private val v: NSUUID) {
actual companion object {
actual fun random(): Uuid = Uuid(NSUUID())
}
actual override fun toString(): String = v.UUIDString
}
3. The DI-preferred cases
Anything that needs lifecycle, context, or state. Example: a secure key/value store.
interface SecureStore {
fun putString(key: String, value: String)
fun getString(key: String): String?
fun remove(key: String)
}
class AndroidSecureStore(context: Context) : SecureStore {
private val prefs = EncryptedSharedPreferences.create(
context, "secure", MasterKey.Builder(context).build(),
PrefKeyEncryptionScheme.AES256_SIV, PrefValueEncryptionScheme.AES256_GCM,
)
override fun putString(key: String, value: String) { prefs.edit().putString(key, value).apply() }
override fun getString(key: String): String? = prefs.getString(key, null)
override fun remove(key: String) { prefs.edit().remove(key).apply() }
}
import platform.Foundation.NSUserDefaults
class IosSecureStore : SecureStore {
private val defaults = NSUserDefaults.standardUserDefaults
override fun putString(key: String, value: String) { defaults.setObject(value, key) }
override fun getString(key: String): String? = defaults.stringForKey(key)
override fun remove(key: String) { defaults.removeObjectForKey(key) }
}
Wire it with Koin:
val coreModule = module { }
actual fun platformModule() = module { single<SecureStore> { AndroidSecureStore(get()) } }
actual fun platformModule() = module { single<SecureStore> { IosSecureStore() } }
Only the factory function name is expect/actual:
expect fun platformModule(): Module
4. expect class rules (Kotlin 2.0)
expect class may declare constructor, members, and supertypes. actual classes must match exactly (same members, same visibility, same supertypes).
expect declarations cannot have bodies (except expect companion object { } which may nest expect fun).
- Default parameter values: allowed on
expect, forbidden on actual (the expect default wins).
- As of Kotlin 2.0,
expect/actual classes are stable but still emit a warning: prefer typealiases to existing JDK/Darwin types when possible.
5. typealias shortcut
If the platform already has the right type, skip the class:
expect class AtomicLong(initial: Long) {
fun addAndGet(delta: Long): Long
fun get(): Long
}
actual typealias AtomicLong = java.util.concurrent.atomic.AtomicLong
actual class AtomicLong actual constructor(initial: Long) {
private val backing = kotlin.native.concurrent.AtomicLong(initial)
actual fun addAndGet(delta: Long): Long = backing.addAndGet(delta)
actual fun get(): Long = backing.value
}
Checklist