expect/actual and Calling Platform APIs
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Common code cannot call UIDevice, android.os.Build or java.io.File: it is compiled for every target, so it
may only use what every target offers. Yet a real app constantly needs platform facilities — the device name, the
time zone, GPS fixes, haptics, secure storage. Kotlin Multiplatform gives you a small set of tools to bridge that
gap, and this page covers all of them: the expect/actual language mechanism, the design patterns that
usually serve better than it (common interfaces, factory functions, entry-point injection and dependency
injection), and the practical details of calling Apple frameworks from iosMain and Android framework APIs from
androidMain.
The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1,
AGP 9.4.1, Xcode 26.4. Every example is a slice of TrailMate Everywhere, the multiplatform version of the
Android section’s TrailMate app (package com.example.trailmate). The layout of
modules and source sets is explained on
Project Structure, Targets, Source Sets and
the Hierarchy Template; the Android Gradle setup on
The Android Target. Kotlin itself (interfaces,
objects, enums, annotations, type aliases) is not re-taught here — see
Kotlin.
The Decision Ladder
Before writing a single expect, walk down this ladder. Each rung costs more than the one above it.
-
Use a multiplatform library. Networking, storage, logging, date and time, settings, permissions and many device features already have common APIs. Search https://klibs.io/ and read Dependencies, klibs.io, cinterop and SwiftPM Import and Essential Multiplatform Libraries.
-
Define an interface in common code and implement it per platform. This is the default design.
-
Use
expect/actualfor small, stateless things — a platform name, a UUID, a clock — or to wire the interface to its implementation. -
Use
expect/actualclasses only when you need to (for example to extend a platform class). They are Beta.
The official guidance is blunt: rely on standard language constructs wherever possible instead of expected and actual declarations. The rest of this page explains why, and when the mechanism is nevertheless the right tool.
How expect/actual Works
An expected declaration is a normal Kotlin declaration marked expect in a common source set, with no
implementation. For every target, a source set must supply an actual declaration — the same construct, marked
actual, with a real implementation. While compiling for one target the compiler merges the pair into a single
declaration, so each call in common code resolves to the right platform code with no runtime dispatch or
reflection.
expect fun platformName(): String"] Android["androidMain
actual fun platformName()"] Ios["iosMain
actual fun platformName()"] Jvm["desktopMain
actual fun platformName()"] Wasm["wasmJsMain
actual fun platformName()"] Common -->|"merged when compiling for Android"| Android Common -->|"merged when compiling for iOS"| Ios Common -->|"merged when compiling for desktop"| Jvm Common -->|"merged when compiling for wasmJs"| Wasm
The first example is TrailMate’s platformName(), used on the About screen. The expected declaration lives in
commonMain; each platform source set provides the actual one.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/platform/Platform.kt
package com.example.trailmate.platform
/** Human-readable name of the running platform, e.g. "Android 17 (API 37)". */
expect fun platformName(): String
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/platform/Platform.android.kt
package com.example.trailmate.platform
import android.os.Build
actual fun platformName(): String = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/platform/Platform.ios.kt
package com.example.trailmate.platform
import platform.UIKit.UIDevice
actual fun platformName(): String =
UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
// sharedLogic/src/desktopMain/kotlin/com/example/trailmate/platform/Platform.desktop.kt
package com.example.trailmate.platform
actual fun platformName(): String =
"Java ${System.getProperty("java.version")} on ${System.getProperty("os.name")}"
Because iosMain is an intermediate source set shared by iosArm64Main and iosSimulatorArm64Main, the single
actual there serves both iOS targets. Do not duplicate it in the leaf source sets.
The Rules the Compiler Enforces
| Rule | Detail |
|---|---|
Every expect needs an actual in every target |
For each target compiled, a source set in its hierarchy must contain a matching |
Same package |
The |
Matching signatures |
Same name, same parameter types, same return type, same type parameters, same supertypes. The IDE reports mismatches as you type. |
Expected declarations have no implementation |
|
Actual may be more visible, not less |
An |
Actuals live in platform or intermediate source sets |
Never in |
A frequent mistake is placing the actual in a different package "because it is platform code": the compiler then
reports the expected declaration as missing its actual and the actual as having no expect. Another is declaring
expect in a source set that is not common to all the targets that use it — an expect in commonMain must be
actualized for every target the module declares, including desktop and web.
Every Kind of Declaration
The mechanism works for functions, properties, classes, objects, interfaces, enumerations and annotations. They differ in how much you should use them.
| Kind | Typical use | Recommendation |
|---|---|---|
|
A stateless operation: |
Preferred form. Stable. |
|
A constant that differs per platform, or a singleton instance. |
Fine for simple values. Stable. |
|
A per-platform singleton with members. |
Works, but a common interface plus an |
|
Rare: you usually declare the interface once in common code. |
Avoid; use a plain common interface. |
|
Wrapping or extending a platform class; framework internals. |
Beta. Needs |
|
Mirroring an existing platform enum. |
Allowed; actuals may add entries. |
|
Mapping to a platform annotation such as a JPA or Android one. |
Allowed; combine with |
Functions and Properties
Functions and properties are the simplest and the most stable case. TrailMate uses a property for the device’s time zone ID and a function for a UUID.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/platform/Environment.kt
package com.example.trailmate.platform
expect val currentTimeZone: String
expect fun randomUuid(): String
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/platform/Environment.android.kt
package com.example.trailmate.platform
import java.util.TimeZone
import java.util.UUID
actual val currentTimeZone: String get() = TimeZone.getDefault().id
actual fun randomUuid(): String = UUID.randomUUID().toString()
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/platform/Environment.ios.kt
package com.example.trailmate.platform
import platform.Foundation.NSTimeZone
import platform.Foundation.NSUUID
import platform.Foundation.defaultTimeZone
actual val currentTimeZone: String get() = NSTimeZone.defaultTimeZone.name
actual fun randomUuid(): String = NSUUID().UUIDString()
Note the get() on the property: without it the value would be captured once at class-initialization time and
miss later changes of the device time zone. In production prefer kotlinx-datetime’s
`TimeZone.currentSystemDefault() and kotlin.uuid.Uuid, which already solve both — these examples are
deliberately small. Prefer properties for values and functions for operations that may be expensive or
change.
Objects
An expect object is a singleton whose members each platform implements:
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/platform/AppInfo.kt
package com.example.trailmate.platform
expect object AppInfo {
val versionName: String
fun isDebuggable(): Boolean
}
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/platform/AppInfo.ios.kt
package com.example.trailmate.platform
import platform.Foundation.NSBundle
actual object AppInfo {
actual val versionName: String =
NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "unknown"
actual fun isDebuggable(): Boolean = kotlin.native.Platform.isDebugBinary
}
On Android, versionName needs a Context (through PackageManager), which an object cannot receive — a typical
sign that an interface supplied at startup would have been a better design (see
Alternatives to expect/actual).
Interfaces and Enumerations
Declare interfaces once, in common code, and implement them in platform code; expect interface buys nothing.
For enumerations, expect enum class lets you actualize with an existing platform enum, and the actual may
add extra entries, which is useful when a platform enum is a superset:
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/platform/TrailSeason.kt
package com.example.trailmate.platform
expect enum class TrailSeason { SPRING, SUMMER, AUTUMN, WINTER }
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/platform/TrailSeason.android.kt
package com.example.trailmate.platform
actual enum class TrailSeason { SPRING, SUMMER, AUTUMN, WINTER, MONSOON }
Code in commonMain can only see the four common entries, so a common when over TrailSeason needs an else
branch; the exhaustiveness check does not know about platform extras. For ordinary domain enums such as
Difficulty, simply declare them in common code (see
Sealed Classes and Enums).
Classes (Beta)
Expected and actual classes are Beta: almost stable, but migration steps may be required in the future. When a module declares one, the compiler warns about the Beta status. To acknowledge it, opt in once per module:
// sharedLogic/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
}
kotlin {
compilerOptions {
// expect/actual classes are Beta -- silences the warning
freeCompilerArgs.add("-Xexpect-actual-classes")
}
android {
namespace = "com.example.trailmate.shared"
compileSdk = 37
minSdk = 26
}
iosArm64()
iosSimulatorArm64()
jvm("desktop")
}
The legitimate use cases are narrow. One is inheriting from a platform class on one target while implementing from scratch on others:
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/platform/TrailLogger.kt
package com.example.trailmate.platform
expect class TrailLogger() {
fun log(message: String)
}
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/platform/TrailLogger.android.kt
package com.example.trailmate.platform
import android.util.Log
actual class TrailLogger {
actual fun log(message: String) {
Log.d("TrailMate", message)
}
}
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/platform/TrailLogger.ios.kt
package com.example.trailmate.platform
import platform.Foundation.NSLog
actual class TrailLogger {
actual fun log(message: String) {
NSLog("TrailMate: %s", message)
}
}
This works, but it is exactly the case the documentation warns about: an interface would let tests substitute a
fake, allow several implementations on one platform, and avoid the Beta flag. In practice, reach for Kermit or
another multiplatform logging library instead. Other cases where a class is justified: a framework author
declaring an expect abstract class whose users derive from it, and wrapping an existing platform class so that
common code gets a uniform type. A recurring mistake is giving an expect class members the actual does not
declare with the identical signature (including nullability and parameter names for named arguments) — the error
appears only when compiling the target that diverged.
Actualizing with a typealias
An actual declaration need not be written from scratch: it may be a typealias to an existing platform type
that satisfies the expected declaration. Time and date types are the classic case, and TrailMate maps its date
wrapper straight onto the JDK type, without wrapping:
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/platform/PlatformDate.kt
package com.example.trailmate.platform
expect class PlatformDate {
fun toEpochMilli(): Long
}
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/platform/PlatformDate.android.kt
package com.example.trailmate.platform
import java.time.Instant
actual typealias PlatformDate = Instant
For this to compile, java.time.Instant must provide every expected member with a matching signature
(toEpochMilli(): Long does). The alias must sit in the same package as the expect, and the aliased class lives
anywhere. There is no way to add the actual keyword to a type you do not own, so a typealias is the only
mechanism for it. The restrictions: the alias cannot add members or change visibility of members, and a
typealias to a generic class must preserve the type parameters. A frequent mistake is aliasing a type whose
nullability or default-parameter shape differs from the expect.
Annotations and @OptionalExpectation
Annotations attach metadata and never appear in signatures, so an expected annotation often matters on only one platform. TrailMate marks classes that Android’s backup agent should serialize:
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/platform/Annotations.kt
package com.example.trailmate.platform
@OptIn(ExperimentalMultiplatform::class)
@OptionalExpectation
expect annotation class Keep()
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/platform/Annotations.android.kt
package com.example.trailmate.platform
actual typealias Keep = androidx.annotation.Keep
With @OptionalExpectation, platforms that never need the annotation (here iOS and desktop) are not required to
provide an actual — the compiler stays silent, and common code may annotate declarations with @Keep, which is
simply ignored on those targets. Without it, every target must supply an actual. @OptionalExpectation is itself
marked with the ExperimentalMultiplatform opt-in, so its use must be opted into as shown (or with the compiler
option -opt-in=kotlin.ExperimentalMultiplatform). Annotation concepts are covered on
Annotations and Reflection. Also note that the
androidx.annotation artifact must be an androidMain dependency.
Alternatives to expect/actual
The most important lesson of the section is that expect/actual is rarely the best design for anything beyond the smallest utilities. The compiler enforces nothing about testability or lifecycle; interfaces do.
| Approach | How it works | Strengths | Weaknesses |
|---|---|---|---|
Common interface + expect factory function |
Interface in |
Code stays in the shared module and is self-sufficient. Fakes are trivial. This is the pattern in projects the IDE wizard generates. |
Factories cannot take platform context (such as Android |
Entry-point injection |
The shared module defines the interface; the Android and iOS apps construct implementations and pass them to a
shared |
No expect/actual at all; implementations may even be written in Swift. Explicit and easy to follow. |
The shared module is not self-sufficient; every consumer must wire it correctly. |
Dependency injection (Koin, Metro) |
Common code declares dependencies as interfaces; a DI module — often an |
Scales; one consistent mechanism; the platform part is a single, small expect/actual. |
A framework is needed; wiring errors surface at runtime for runtime-based containers such as Koin. |
|
The class itself is expected and actualized. |
Can extend platform classes; no interface needed. |
Beta; one implementation per target; harder to fake; flag required. |
Multiplatform library |
Use a library that already hides the platform. |
No platform code to maintain. |
Dependency and API surface you do not control. |
The official recommendation: if a project already uses a DI framework, use it for platform dependencies as well, so you do not mix two ways of injecting. The next sections implement the same capability — TrailMate’s location updates — with each rung of the ladder.
TrailMate’s LocationProvider
Recording a hike needs device location. The shared RecordHikeUseCase must not know whether the fix comes from
Google Play services or Core Location, so it depends on a common interface.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/location/LocationProvider.kt
package com.example.trailmate.location
import kotlinx.coroutines.flow.Flow
data class GeoPoint(val latitude: Double, val longitude: Double)
interface LocationProvider {
/** Cold flow of location fixes; collection starts updates, cancellation stops them. */
fun locationUpdates(): Flow<GeoPoint>
}
AndroidLocationProvider and IosLocationProvider live in their platform source sets and are never referenced
from common code. The common side only sees the interface; how it gets an instance is the topic of the
Wiring the Implementation section.
Android: androidMain
In androidMain you have the full Android SDK and any Android dependency declared for that source set. Here the
implementation uses the fused location provider. The Android location APIs and the permission flow are covered on
Camera, Location, Sensors, Media and
Connectivity and
Permissions, Privacy and Security.
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/location/AndroidLocationProvider.kt
package com.example.trailmate.location
import android.annotation.SuppressLint
import android.content.Context
import android.os.Looper
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
class AndroidLocationProvider(private val context: Context) : LocationProvider {
@SuppressLint("MissingPermission") // the app requests ACCESS_FINE_LOCATION before collecting
override fun locationUpdates(): Flow<GeoPoint> = callbackFlow {
val client = LocationServices.getFusedLocationProviderClient(context)
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5_000L).build()
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { trySend(GeoPoint(it.latitude, it.longitude)) }
}
}
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
awaitClose { client.removeLocationUpdates(callback) }
}
}
// sharedLogic/build.gradle.kts (excerpt)
kotlin {
sourceSets {
androidMain.dependencies {
implementation("com.google.android.gms:play-services-location:21.3.0")
}
}
}
Android-only dependencies are scoped to androidMain; they never reach common or iOS code. Because the class
takes a Context, the instance must be created where a context exists — the androidApp module or a Koin
androidContext(). That is why a plain expect fun createLocationProvider() cannot build it without a global.
iOS: iosMain and Platform Libraries
Kotlin/Native ships platform libraries: prebuilt Kotlin bindings, per target, to the operating system’s own
frameworks. On Apple targets they cover Foundation, UIKit, CoreLocation, AVFoundation, Security and the rest of the
SDK. Their packages are available by default under platform.* — no dependency declaration, no extra link
options; the compiler detects which ones you use and links them. They are only wrappers: the Apple SDK that
Xcode provides must be installed, which is why Apple targets compile only on macOS.
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/location/IosLocationProvider.kt
package com.example.trailmate.location
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import platform.CoreLocation.CLLocation
import platform.CoreLocation.CLLocationManager
import platform.CoreLocation.CLLocationManagerDelegateProtocol
import platform.Foundation.NSError
import platform.darwin.NSObject
@OptIn(ExperimentalForeignApi::class)
class IosLocationProvider : LocationProvider {
override fun locationUpdates(): Flow<GeoPoint> = callbackFlow {
val manager = CLLocationManager()
val delegate = object : NSObject(), CLLocationManagerDelegateProtocol {
override fun locationManager(manager: CLLocationManager, didUpdateLocations: List<*>) {
val last = didUpdateLocations.lastOrNull() as? CLLocation ?: return
last.coordinate.useContents { trySend(GeoPoint(latitude, longitude)) }
}
override fun locationManager(manager: CLLocationManager, didFailWithError: NSError) {
close(IllegalStateException(didFailWithError.localizedDescription))
}
}
manager.delegate = delegate
manager.requestWhenInUseAuthorization()
manager.startUpdatingLocation()
awaitClose {
manager.stopUpdatingLocation()
manager.delegate = null
}
}
}
Several details here are typical of calling Apple APIs from Kotlin:
-
Naming. Objective-C selectors become Kotlin methods:
locationManager:didUpdateLocations:islocationManager(manager, didUpdateLocations = …). Overloads that differ only by label are distinguished by their parameter types. -
Delegates are protocols. Implement
CLLocationManagerDelegateProtocolon anNSObjectsubclass. Apple’sdelegateproperty is weak, so the delegate must be kept reachable — here it is held by thecallbackFlowlambda’s scope untilawaitClosecompletes. -
C structs and pointers such as
CLLocationCoordinate2Dare exposed throughkotlinx.cinterop; read their fields in auseContents { }block. These APIs require@OptIn(ExperimentalForeignApi::class). -
Permissions are declared on the Apple side.
NSLocationWhenInUseUsageDescriptionmust be iniosApp/iosApp/Info.plist, otherwise the prompt never appears. See Location and Maps and Security and Privacy. -
Threading. Core Location calls back on the thread that created the manager’s run loop; collect the flow on the main dispatcher. Concurrency is covered on Coroutines, Flow and Concurrency.
The same mechanism covers platform.Foundation (dates, URLs, bundles, NSUserDefaults), platform.UIKit
(UIDevice, UIApplication, UIViewController) and platform.posix (the POSIX library, available on UNIX- and
Windows-based targets including Android and iOS). Only Apple SDK frameworks that the Kotlin/Native distribution
bundles are visible by default; third-party Objective-C or Swift libraries need cinterop or Swift Package Manager
import, covered in
Dependencies, klibs.io, cinterop and
SwiftPM Import. How the Kotlin side looks from Swift is on
Objective-C, Swift and Swift Export Interop.
Which Source Set Can See What
| Source set | Visible platform APIs | Common pitfall |
|---|---|---|
|
Only the Kotlin standard library and common dependencies. |
Importing |
|
Android SDK, Java/JVM APIs and |
Assuming the code also runs on desktop JVM; desktop has no |
|
All |
Forgetting |
|
Foundation, |
Using UIKit here; UIKit is iOS/tvOS only. It is not available for macOS. |
|
Java and Swing/AWT APIs; no Android. |
Sharing code with |
The compile-time visibility rules follow from the hierarchy template; see Kotlin docs — Hierarchical project structure.
Wiring the Implementation
With LocationProvider implemented on both platforms, the app must choose an implementation. The three options
from the table above look like this.
Option 1: an expect factory function
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/location/LocationProviderFactory.kt
package com.example.trailmate.location
expect fun createLocationProvider(): LocationProvider
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/location/LocationProviderFactory.ios.kt
package com.example.trailmate.location
actual fun createLocationProvider(): LocationProvider = IosLocationProvider()
This is clean on iOS; on Android the factory would need a Context, so you would add a parameter to the expect
(expect fun createLocationProvider(platformContext: PlatformContext) where PlatformContext is itself an
expect class — the pattern used by Coil 3 and similar libraries) or stash the application context in a global.
Both work; neither is as simple as the next option.
Option 2: entry-point injection
The Android app creates its implementation and the iOS app creates its own, then both pass it to the shared module. Because the interface is an ordinary Kotlin interface, the iOS implementation could even be written in Swift.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/Shared.kt
package com.example.trailmate
import com.example.trailmate.location.LocationProvider
import com.example.trailmate.platform.Haptics
fun initShared(locationProvider: LocationProvider, haptics: Haptics) {
TrailMateGraph.install(locationProvider, haptics)
}
interface Haptics {
fun trailCompleted()
}
// iosApp/iosApp/SwiftHaptics.swift
import UIKit
import SharedLogic
final class SwiftHaptics: Haptics {
func trailCompleted() {
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
}
@main
struct TrailMateApp: App {
init() {
SharedKt.initShared(
locationProvider: IosLocationProvider(),
haptics: SwiftHaptics()
)
}
var body: some Scene { WindowGroup { RootView() } }
}
Swift can implement a Kotlin interface directly; the Kotlin compiler exports it as an Objective-C protocol. The
framework name (SharedLogic) and the file-facade class (SharedKt for Shared.kt) come from the Gradle
binaries.framework configuration and the file name; see
iOS Integration Options. Swift UI basics are in
SwiftUI Views and Modifiers.
Option 3: dependency injection with a platform module
With Koin, only the module that supplies platform bindings is expected and actualized; everything else is a common module. This is the arrangement TrailMate Everywhere ships.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/PlatformModule.kt
package com.example.trailmate.di
import org.koin.core.module.Module
expect val platformModule: Module
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/di/PlatformModule.android.kt
package com.example.trailmate.di
import com.example.trailmate.location.AndroidLocationProvider
import com.example.trailmate.location.LocationProvider
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
actual val platformModule: Module = module {
single<LocationProvider> { AndroidLocationProvider(androidContext()) }
}
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/di/PlatformModule.ios.kt
package com.example.trailmate.di
import com.example.trailmate.location.IosLocationProvider
import com.example.trailmate.location.LocationProvider
import org.koin.dsl.module
actual val platformModule: Module = module {
single<LocationProvider> { IosLocationProvider() }
}
androidContext() is why DI fits the Android side so well: Koin hands the Context to the constructor. The
application’s startKoin { androidContext(this@TrailMateApp); modules(trailMateModule, platformModule) } call
lives in the Android app. Koin 4.2.2 setup, scopes and Compose integration are covered on
Dependency Injection with Koin and Metro,
and the Android-only variant on
Dependency Injection: Hilt and Koin.
Testing and Fakes
Because LocationProvider is an interface, tests in commonTest substitute a fake with no platform setup:
// sharedLogic/src/commonTest/kotlin/com/example/trailmate/location/FakeLocationProvider.kt
package com.example.trailmate.location
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class FakeLocationProvider(private val points: List<GeoPoint>) : LocationProvider {
override fun locationUpdates(): Flow<GeoPoint> = flowOf(*points.toTypedArray())
}
An expect object or expect fun cannot be replaced this way: tests would run against whichever actual the test
target compiles. This is the strongest argument for interfaces. See
Testing for the full strategy, and
Kotlin docs — Expected and actual declarations
for the language rules.
Common Mistakes
| Mistake | Fix |
|---|---|
Actual placed in a different package than the expect |
Use the identical package; the file location is irrelevant. |
Missing actual for one target (often desktop or web) |
Compile every declared target in CI; add the actual or an interface-based design. |
|
Define a common interface and use a factory or DI; keep expect classes for platform inheritance. |
Beta warning on expect classes ignored in a library |
Add |
Default values repeated on the actual |
Defaults belong on the expect only. |
Extra enum entries relied on in common code |
Common code sees only the expected entries; add an |
Global |
Pass it in at the entry point or through Koin’s |
Assuming |
Android and desktop are separate targets; share through common code, not the JVM. |
Calling UIKit APIs off the main thread |
Switch to the main dispatcher before touching |
Using a platform library not bundled with Kotlin/Native |
Use cinterop or SwiftPM import (Alpha) for third-party native libraries. |
Summary
-
Prefer, in order: a multiplatform library, a common interface, small
expect fun/expect valdeclarations, and only thenexpect class(Beta, needs-Xexpect-actual-classes). -
An
actualmust be in the same package as itsexpect, match its signature, and exist for every target;typealiasactualization maps expects to existing platform types. -
@OptionalExpectationmakes an expected annotation optional on platforms that never need it, and requires an opt-in toExperimentalMultiplatform. -
Android APIs live in
androidMain; Foundation, UIKit and CoreLocation come from Kotlin/Native platform libraries iniosMain; both reach common code through an interface such asLocationProvider. -
Wire implementations with a factory, entry-point injection or a DI platform module; use the same mechanism the project already uses for the rest of its dependencies.