The first post built a reading list that runs on Android, iOS, the desktop and the web, with one line of platform code per target. That is the easy case. This post adds two features that real apps need right away: the list should survive a restart, and a book should be shareable with another app. Saving needs each platform’s own storage, and on Android that storage needs a Context. Sharing does not exist on every platform at all.

Kotlin Multiplatform’s best-known tool for this is the pair expect and actual. This post shows what that pair actually guarantees, why it is usually not the first tool to reach for, and how to model a feature that some platforms lack, so that it disappears where it is missing instead of crashing there. It then makes thirteen common mistakes on purpose, compiles each one for every target, and records what the compiler says.

1. What expect and actual Promise

An expect declaration in commonMain says “every target will provide this”. Each platform source set then provides it with actual:

// commonMain
expect fun platformName(): String
 
// androidMain
actual fun platformName(): String = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
 
// jvmMain
actual fun platformName(): String = "desktop JVM ${System.getProperty("java.version")} on ${System.getProperty("os.name")}"

The promise is checked by the compiler, separately for each target. When Kotlin compiles the Android target, it compiles commonMain plus the source sets on Android’s path, and every expect there must meet exactly one actual:

  • with the same name and package (the fully qualified name must match);
  • with a compatible signature: the same parameters, return type, type parameters and visibility;
  • with default argument values only on the expect, never on the actual;
  • and one actual per target: an intermediate source set such as nonAndroidMain can provide it for all the targets below it, but then no target below may provide it again.
commonMainexpect fun platformName(): String@Composable expect fun appColorScheme(): ColorSchemeAndroid compilationandroidMainactual fun platformName()androidMainactual fun appColorScheme()dynamic colour on API 31+desktop JVM compilationjvmMainactual fun platformName()web (Wasm) compilationwasmJsMainactual fun platformName()iOS compilationiosMainactual fun platformName()nonAndroidMain, on the path of all threeactual fun appColorScheme()the default light or dark scheme, written onceEach target is compiled on its own: commonMain plus the source sets on its path. Every expect must find exactly one actual there,with the same name, package and signature. A missing or extra actual fails that target's compilation, not the others.

Two things follow from this, and the rest of the post depends on both.

  • The check is per target. A missing actual on the web fails the web compilation and nothing else. A team that builds only the Android app never sees it.
  • The check is about declarations, not behaviour. The compiler verifies that an actual exists and has the right signature. It does not look at what the actual does. An actual that throws, or that reads a global that was never set, compiles on every target.

2. Reach for expect Last

The Kotlin documentation is direct about this: rely on standard language constructs where possible, and use expect/actual only where they are needed. It explicitly recommends against expect classes “in simple cases where interfaces would be sufficient”, because an interface allows more than one implementation per platform and a fake one in tests. Expected and actual classes are still Beta, and the compiler warns on every one of them unless a flag is set (Section 5 shows the warning).

In practice that turns into a short list of questions, asked in order:

Does a multiplatform library already do it?Use it: no platform code at allkotlinx-datetime, kotlinx-serialization, Ktor, RoomyesnoOne stateless function or value?expect fun / expect valplatformName(); appColorScheme()yesnoNeeds state, a Context, severalimplementations, or a fake in tests?an interface, one class per platform, passed inKeyValueStore, built by each entry pointyesnoMissing on some targets?at compile time: an intermediate source setat run time: a nullable capability (sharer?)yesnoMust be an existing platform type?actual typealias to itexpect class only as a last resort (Beta)yes

The order matters because each step costs more than the one before. A library costs nothing to maintain. An expect fun costs one small function per target. An interface costs a class per platform and a line in each entry point, but it can carry state, take a Context, and be replaced in a test. expect class is last, because it combines the restrictions of both: one implementation per target, no fake in tests, and a Beta warning.

Now in Android KMP, measured in the first post, follows the same rule: its 8,243 lines contain only 14 expect declarations, and its services, the network client, the database and the repositories, are all ordinary interfaces and classes.


3. The Context Problem

Saving the reading list needs a key-value store, and every platform has one: SharedPreferences on Android, NSUserDefaults on iOS, localStorage in the browser, and a file on the desktop. The tempting version is an expect fun:

// commonMain
expect fun createStore(): KeyValueStore
 
// androidMain
lateinit var appContext: Context                 // set somewhere at startup, hopefully
actual fun createStore(): KeyValueStore = PrefsStore(appContext)

On Android, SharedPreferences comes from a Context, and a parameterless expect fun has no way to receive one, so the Context goes into a global. This compiles on all six compilations of the project. The first time anything calls createStore() before the global is set, it crashes. Section 5’s experiment ran exactly this in a test on Android:

kotlin.UninitializedPropertyAccessException: lateinit property appContext has not been initialized

Setting the global early, in Application.onCreate() or with an initializer library, makes the crash rarer, not impossible. It also hides the dependency: nothing in createStore()’s signature says it needs Android to be set up first, and a test cannot hand it a different store.

The version used in the app makes the store an interface in commonMain, and lets each entry point, which already has whatever the platform requires, build its own:

// commonMain
interface KeyValueStore {
    fun get(key: String): String?
    fun put(key: String, value: String)
}
 
class PlatformServices(val store: KeyValueStore, val sharer: Sharer?)
 
@Composable
fun App(services: PlatformServices) { … }
// androidMain: the Context arrives as a parameter, from the Activity
class PrefsStore(context: Context) : KeyValueStore {
    private val prefs = context.getSharedPreferences("reading-list", Context.MODE_PRIVATE)
    override fun get(key: String): String? = prefs.getString(key, null)
    override fun put(key: String, value: String) = prefs.edit().putString(key, value).apply()
}
 
// androidApp: MainActivity.onCreate
val services = androidServices(this)
setContent { App(services) }

The desktop, web and iOS entry points do the same with FileStore, LocalStorageStore and UserDefaultsStore. Nothing is global, and nothing can be called before it exists, because the store is created where the platform is available and passed down.

The interface also pays for itself in tests. ReadingList takes a KeyValueStore, so commonTest gives it an in-memory one:

class MemoryStore : KeyValueStore {
    val values = mutableMapOf<String, String>()
    override fun get(key: String) = values[key]
    override fun put(key: String, value: String) { values[key] = value }
}
 
@Test fun changesSurviveARestart() {
    val store = MemoryStore()
    ReadingList(store).apply { add("Effective Kotlin", "Moskała"); toggle(1); remove(2) }
    val reopened = ReadingList(store)
    // … the added book is there, book 1 is read, book 2 is gone, and ids keep counting
}

The four tests in commonTest ran unchanged on three targets: the desktop JVM, the browser (Kotlin/Wasm in headless Chrome) and Android’s host-side JVM. With an expect fun createStore(), the same test would need a platform-specific store on every target, or the global.

One platform detail turned up while writing the desktop store. java.util.prefs.Preferences, the obvious choice on a JVM, limits a value to 8,192 characters (Preferences.MAX_VALUE_LENGTH). A reading list stored as JSON passes that at roughly a hundred books, so the desktop store writes a file instead. Because the store is an interface, that decision stays inside one small class.


4. Features Some Platforms Do Not Have

Saving exists everywhere. Some features do not, and they come in two kinds.

Known per target: a source set

Dynamic colour (Material You) derives the app’s colours from the wallpaper. It exists only on Android, from Android 12 (API 31). Whether a target can have it is known at compile time, so it belongs in source sets: the real version in androidMain, and one fallback shared by the other three targets in a custom intermediate source set, nonAndroidMain:

// commonMain
@Composable
expect fun appColorScheme(): ColorScheme
 
// androidMain
@Composable
actual fun appColorScheme(): ColorScheme {
    val dark = isSystemInDarkTheme()
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {   // dynamic colour exists from Android 12 (API 31)
        val context = LocalContext.current
        return if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
    }
    return if (dark) darkColorScheme() else lightColorScheme()
}
 
// nonAndroidMain: one actual for iOS, desktop and the browser
@Composable
actual fun appColorScheme(): ColorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()

This is a good use of expect fun: stateless, and a @Composable can get the Context from LocalContext without a global. Note that the Android version still checks the API level at run time; the source set settles which targets can have the feature, not which devices.

nonAndroidMain is not part of Kotlin’s default hierarchy, so it is declared in Gradle. Adding a group to the default hierarchy template keeps everything else the template provides (appleMain, iosMain, webMain and the rest):

kotlin {
    @OptIn(ExperimentalKotlinGradlePluginApi::class)
    applyDefaultHierarchyTemplate {
        common {
            group("nonAndroid") {
                withJvm()
                withWasmJs()
                withIos()
            }
        }
    }
}

The older way, a manual nonAndroidMain.dependsOn(commonMain) and jvmMain.dependsOn(nonAndroidMain), still works, but any manual dependsOn switches the default hierarchy template off for the whole project, and Gradle warns about it. The template DSL above is still marked experimental, hence the opt-in.

Known only at run time: a nullable capability

Sharing is different. Android and iOS always have a share sheet, a JVM never has one, and in a browser it depends on the browser: the Web Share API (navigator.share) exists in Chrome, Edge and Safari, but not in Firefox on the desktop, it needs HTTPS and a user’s click, and inside an iframe the page must allow it. For the web target, the answer is only known when the app runs.

The usual mistake is to give every platform an actual and make the unsupported ones fail:

// jvmMain: compiles, and crashes when the user taps "Share"
actual fun share(text: String): Unit = TODO("not on desktop")

or silently do nothing, which is a button that does not work. The app instead models sharing as a capability that may be absent:

// commonMain
fun interface Sharer {
    fun share(text: String)
}
class PlatformServices(val store: KeyValueStore, val sharer: Sharer?)   // null: no share sheet here
 
// in App(): the button exists only where sharing does
services.sharer?.let { sharer ->
    TextButton(onClick = { sharer.share("${book.title} by ${book.author}") }) { Text("Share") }
}

Each entry point decides. Android passes an IntentSharer (an ACTION_SEND chooser), iOS a Sharer that presents a UIActivityViewController, and the desktop passes null. The web build checks the browser once at startup, through Kotlin/Wasm’s JavaScript interop:

// wasmJsMain
private fun canShare(): Boolean = js("typeof navigator.share === 'function'")
private fun webShare(text: String): Unit = js("{ navigator.share({ text: text }).catch(function () {}); }")
 
fun webServices() = PlatformServices(LocalStorageStore(), if (canShare()) Sharer(::webShare) else null)

The type does the enforcing. Sharer? cannot be called without a null check: in Section 5, a version that writes services.sharer.share(…) fails in five of the six compilations, all but the nonAndroidMain metadata compilation, which does not contain App.kt. The capability cannot be forgotten, and a platform without it shows no button instead of a crash.


5. Thirteen Mistakes, Compiled

To see what the compiler actually catches, a script makes each mistake below in a copy of the app, compiles all six compilations (the shared commonMain and nonAndroidMain metadata, Android, desktop JVM, web and iOS), and records the result. The two mistakes that compile were then run in a test.

MistakeWhat failsCaught
An actual is missing on the webweb onlycompile
The actual keyword is forgotten (desktop)desktop onlycompile
The actual returns Int instead of Stringdesktop onlycompile
The actual is in another package (Android)Android onlycompile
A default argument on the actualdesktop onlycompile
Two actuals on one path (jvmMain and nonAndroidMain)desktop onlycompile
android.os.Build used in commonMaineverything except Androidcompile
java.io.File used in nonAndroidMainweb, iOS, nonAndroidMain (not desktop)compile
services.sharer.share(…) without a null check5 of 6 compilationscompile
A platform class misses a new interface memberdesktop onlycompile
An expect class instead of an interfacenothingBeta warning
An actual that is TODO()nothingrun time (desktop)
A lateinit global Context behind an expect funnothingrun time (Android)

The compiler’s messages are clear and point at the right line. For example:

Platform.kt:4:1 The 'expect' declaration 'platformName' has no 'actual' declaration in module '<commonMain> for wasm-js'.
Platform.jvm.kt:3:12 The 'expect' and the 'actual' declarations are incompatible.
    expect: public final expect fun platformName(): String
    actual: public final actual fun platformName(): Int
    reason: the return types are different
Platform.jvm.kt:3:25 'actual' function cannot have default argument values. They must be declared in the 'expect' function.
Sdk.kt:3:23 Unresolved reference 'android'.

Three things stand out.

  1. Most mistakes break one target only. Seven of the ten compile errors show up in a single platform compilation. A developer who runs only the Android app sees none of them, except the wrong package on Android.
  2. The worst one is invisible on Android. android.os.Build in commonMain compiles for Android, because on Android android.* exists. It fails for every other target and for the shared metadata compilation. The same happens one level down: java.io.File in nonAndroidMain compiles for the desktop and fails for the web and iOS. CI should compile every target, or at least run compileCommonMainKotlinMetadata and the other metadata compilations, which catch these without building any platform.
  3. What compiles is what crashes. The two mistakes that passed all six compilations, TODO() and the lateinit global, are exactly the two the earlier sections replaced: the first with a nullable capability, the second with an interface passed in by the entry point.

The iOS column needs a note. Without Xcode, the Kotlin/Native compiler still type-checks iOS code (here with only Apple’s Command Line Tools installed); linking a framework or a test binary is what needs Xcode. So a Mac without Xcode can still compile, and check, the iOS source sets.


6. The Reading List, Version 2

The app now saves its list, can share a book where the platform can, and uses dynamic colour on Android 12 and later:

reading-list-2/shared/src/
├── commonMain/      ReadingList.kt (saved as JSON with kotlinx-serialization), App.kt,
│                    Services.kt (KeyValueStore, Sharer, PlatformServices), Theme.kt, Platform.kt
├── commonTest/      ReadingListTest.kt, with MemoryStore
├── nonAndroidMain/  Theme.nonAndroid.kt: one fallback colour scheme for iOS, desktop and web
├── androidMain/     PrefsStore, IntentSharer, dynamic colour, platformName
├── jvmMain/         FileStore, no sharer; the desktop main()
├── wasmJsMain/      LocalStorageStore, the Web Share check; the browser main()
└── iosMain/         UserDefaultsStore, a UIActivityViewController sharer, MainViewController()

This is it, compiled to WebAssembly. Add a book or tick one off, then reload the page: the list comes back. The Share button appears only if this browser has the Web Share API. (The page embeds the chapter’s latest version of the app, which since the next post keeps the list in a SQLite database instead of localStorage; the code and sizes in this post are those of version 2, in the download below.)

In headless Chrome on macOS, a list written to localStorage before loading was shown after the reload, and removing a book and unticking one changed what was saved. The Share button appeared there, since Chrome on macOS has the Web Share API; the share sheet itself was not opened in that test.

The platform code grew, as it should have:

TargetLines it compilesOf which in commonMain
Android (with androidApp)12375.6%
iOS10786.9%
desktop11084.5%
web11183.8%

Over the whole project, 93 of 168 lines (55%) are in commonMain, down from 76% in version 1. That is not a step backwards: each platform now has a real job, saving and sharing, and each job is a small adapter of 12 to 22 lines with no logic in it. All the rules (what counts as a duplicate, what is saved, what happens to unreadable data) are in commonMain, which is why they could be tested once and run on three targets. The 30 lines of commonTest are not counted above.

The new features cost little in the downloads: the Android release APK went from 1.21 MB to 1.24 MB, and the web build from 4.45 MB to 4.55 MB compressed. The app’s own Wasm module grew from 3.35 MB to 3.59 MB, mostly kotlinx-serialization; Skia, the larger file, is byte-for-byte the same.

To build it, download the project (reading-list-2.zip, 61 KB), unzip it, and with JDK 21 and the Android SDK:

cd reading-list-2
./gradlew :shared:jvmTest :shared:testAndroidHostTest     # the shared tests on the JVM and on Android's JVM
CHROME_BIN="/path/to/chrome" ./gradlew :shared:wasmJsBrowserTest   # … and in a browser, as Wasm
./gradlew :androidApp:assembleRelease                     # Android APK
./gradlew :shared:run                                     # desktop window
./gradlew :shared:wasmJsBrowserDistribution               # the browser build in shared/build/dist/wasmJs
./gradlew -Pkmp.ios=true :shared:compileKotlinIosSimulatorArm64   # type-check iOS (no Xcode needed)

7. Common Claims, Checked

ClaimVerdict
”expect/actual is how you write platform code in KMP.”It is one tool, and the Kotlin documentation recommends standard constructs (interfaces, plain classes, libraries) first. Now in Android KMP has 14 expect declarations in 8,243 lines.
”If it compiles, every platform has a working implementation.”It has a declaration with the right signature. A TODO() actual and a lateinit global both compiled on all six compilations and crashed at run time.
”expect class is the multiplatform way to wrap a platform class.”It is Beta and warns on every declaration. An interface (with a fake for tests) or an actual typealias to the existing platform type usually fits better.
”A missing actual breaks the build.”It breaks the compilation of the target that lacks it. The other targets still build, so the gap goes unnoticed until someone builds that target.
”If the Android app compiles, the shared code is fine.”android.os.Build in commonMain compiles for Android and fails for desktop, web, iOS and the shared metadata compilation.
”For an unsupported platform, throw in the actual.”That turns a missing feature into a crash. A nullable capability lets the UI leave the feature out, and the compiler forces the check.
”Platform availability is a compile-time question.”Per target, yes. Per device, no: dynamic colour needs API 31 within Android, and Web Share depends on the browser. Both need a run-time check as well.
”Intermediate source sets need dependsOn.”Manual dependsOn turns off the default hierarchy template. Adding a group to applyDefaultHierarchyTemplate { } keeps it.
”iOS code cannot be checked without Xcode.”The Kotlin/Native compiler type-checks it with only the Command Line Tools installed. Linking a framework or a test binary needs Xcode.

Summary

IdeaIn one line
The promisePer target: one actual with the same name, package and signature; defaults only on the expect
The limitThe compiler checks declarations, not behaviour: TODO() and globals compile
The orderA library, then expect fun, then an interface passed in, then source sets or a nullable capability, expect class last
ContextComes in through the entry point, never through a global
Missing featuresPer target: a source set (nonAndroidMain); per device or browser: Sharer?
CICompile every target, or at least the metadata compilations: most mistakes break one target only

Next: One Database on Four Platforms: Room in Kotlin Multiplatform: the key-value store replaced by one Room database on Android, iOS, the desktop and the web, a list that updates itself through a Flow, SQLite in a browser worker, and what it all costs.

The app: reading-list-2 (zip) (Kotlin 2.4.21, Compose Multiplatform 1.12.2, kotlinx-serialization 1.11.0, AGP 9.4.1, Gradle 9.8.1). The experiment: expect_mistakes.py, which writes the lab’s data. Sizes and results are from this machine (macOS, Apple silicon, JDK 21, without Xcode); the iOS code was compiled but not linked or run.


References

expect and actual, source sets

Platform APIs