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.

  1. 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.

  2. Define an interface in common code and implement it per platform. This is the default design.

  3. Use expect/actual for small, stateless things — a platform name, a UUID, a clock — or to wire the interface to its implementation.

  4. Use expect/actual classes 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.

flowchart LR Common["commonMain
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 actual. A missing one is a compile error for that target only — so a project that builds on Android may fail on iOS.

Same package

The actual must be in the same package as the expect (com.example.trailmate.platform above). The file name and the source set directory are free; the package is what matters.

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

expect fun has no body, expect val has no initializer. Default parameter values go on the expect only; the actual must not repeat them.

Actual may be more visible, not less

An actual can widen visibility (for example an internal expect class actualized by a public type alias). Narrowing is an error. Widening through a type alias historically needed @Suppress("ACTUAL_WITHOUT_EXPECT"); the official docs state the limitation no longer applies since Kotlin 2.0.

Actuals live in platform or intermediate source sets

Never in commonMain itself. Put iOS actuals in iosMain (or appleMain if macOS should share them).

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

expect fun

A stateless operation: platformName(), randomUuid(), currentTimeZone(), or a factory returning a common interface.

Preferred form. Stable.

expect val

A constant that differs per platform, or a singleton instance.

Fine for simple values. Stable.

expect object

A per-platform singleton with members.

Works, but a common interface plus an expect val is easier to fake in tests.

expect interface

Rare: you usually declare the interface once in common code.

Avoid; use a plain common interface.

expect class

Wrapping or extending a platform class; framework internals.

Beta. Needs -Xexpect-actual-classes. Last resort.

expect enum class

Mirroring an existing platform enum.

Allowed; actuals may add entries.

expect annotation class

Mapping to a platform annotation such as a JPA or Android one.

Allowed; combine with @OptionalExpectation.

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 commonMain; expect fun createX(): X; each platform actualizes the factory.

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 Context) without extra arguments or globals.

Entry-point injection

The shared module defines the interface; the Android and iOS apps construct implementations and pass them to a shared init/start function.

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 expect val platformModule —  provides the platform implementations.

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.

expect/actual class

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>
}
classDiagram class LocationProvider { <<interface>> +locationUpdates() Flow~GeoPoint~ } class GeoPoint { <<data class>> +latitude Double +longitude Double } class AndroidLocationProvider { -context Context +locationUpdates() Flow~GeoPoint~ } class IosLocationProvider { +locationUpdates() Flow~GeoPoint~ } class RecordHikeUseCase { +invoke() Flow~GeoPoint~ } LocationProvider <|.. AndroidLocationProvider : androidMain LocationProvider <|.. IosLocationProvider : iosMain LocationProvider ..> GeoPoint RecordHikeUseCase --> LocationProvider : commonMain

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: is locationManager(manager, didUpdateLocations = …​). Overloads that differ only by label are distinguished by their parameter types.

  • Delegates are protocols. Implement CLLocationManagerDelegateProtocol on an NSObject subclass. Apple’s delegate property is weak, so the delegate must be kept reachable — here it is held by the callbackFlow lambda’s scope until awaitClose completes.

  • C structs and pointers such as CLLocationCoordinate2D are exposed through kotlinx.cinterop; read their fields in a useContents { } block. These APIs require @OptIn(ExperimentalForeignApi::class).

  • Permissions are declared on the Apple side. NSLocationWhenInUseUsageDescription must be in iosApp/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

commonMain

Only the Kotlin standard library and common dependencies.

Importing android., platform. or java.* fails here.

androidMain

Android SDK, Java/JVM APIs and androidMain dependencies.

Assuming the code also runs on desktop JVM; desktop has no android.*.

iosMain

All platform.* libraries for iOS (UIKit, Foundation, CoreLocation…​).

Forgetting @OptIn(ExperimentalForeignApi::class) for pointer-based APIs.

appleMain

Foundation, platform.darwin and other APIs common to every Apple target.

Using UIKit here; UIKit is iOS/tvOS only. It is not available for macOS.

desktopMain (JVM)

Java and Swing/AWT APIs; no Android.

Sharing code with androidMain through a JVM source set, which is not part of the default hierarchy.

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.

expect class used where an interface would do

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 -Xexpect-actual-classes knowingly, and document that the API may need migration.

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 else branch.

Global Context stashed in a top-level var for factories

Pass it in at the entry point or through Koin’s androidContext().

Assuming androidMain code also runs on desktop JVM

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 UIViewController or other UI objects.

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 val declarations, and only then expect class (Beta, needs -Xexpect-actual-classes).

  • An actual must be in the same package as its expect, match its signature, and exist for every target; typealias actualization maps expects to existing platform types.

  • @OptionalExpectation makes an expected annotation optional on platforms that never need it, and requires an opt-in to ExperimentalMultiplatform.

  • Android APIs live in androidMain; Foundation, UIKit and CoreLocation come from Kotlin/Native platform libraries in iosMain; both reach common code through an interface such as LocationProvider.

  • 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.