Shared business logic still needs a handful of things only the platform knows: the OS version, a secure store, a UUID generator. expect/actual is Kotlin Multiplatform's mechanism for declaring that need once in common code and satisfying it separately on each target, with the compiler enforcing the contract.
#The old way
Before KMP, this meant two parallel classes with no shared type: a PlatformInfo object on Android built from android.os.Build, and a completely separate Swift PlatformInfo struct on iOS reading UIDevice. Any code that needed platform info had to be duplicated alongside it, once per codebase.
#The new way
expect class Platform() {
val name: String
val osVersion: String
}
fun buildUserAgent(): String {
val platform = Platform()
return "MyApp/1.0 (${platform.name}; ${platform.osVersion})"
}
actual class Platform actual constructor() {
actual val name: String = "Android"
actual val osVersion: String = android.os.Build.VERSION.RELEASE
}
actual class Platform actual constructor() {
actual val name: String = "iOS"
actual val osVersion: String = UIDevice.currentDevice.systemVersion
}
buildUserAgent() lives once in commonMain and compiles against whichever actual class Platform is on the classpath for the target being built.
#Why it matters
- One call site (
buildUserAgent()) instead of two divergent implementations that can silently drift apart - The compiler fails the build if an actual declaration is missing or its signature doesn't line up, not at runtime
- Shared code can depend on platform behavior without depending on any platform framework directly
#Gotcha
expect/actual class declarations are still gated behind an opt-in on Kotlin 1.9.x — without @OptIn(ExperimentalMultiplatform::class) on the expect declaration, or -Xexpect-actual-classes in your compiler args, the build emits a warning (and on some toolchain configurations, an error) even though the code is otherwise correct. Function- and property-level expect/actual don't need this; only expect classes do.