Kmp Expect Actual
ASecurityThe expect/actual mechanism in Kotlin Multiplatform — when to use it, when NOT to, and how to do it correctly. Covers: the four categories that genuinely warrant expect/actual (platform APIs, platform types, performance-critical code, SDK integration), the common-first rule that prefers a pure `commonMain` implementation before abstractions, the interface-injection alternative that handles most cases better, the "actual everywhere" anti-pattern, typealias actual for platform types, @ObjCName ...
- 2 stars
- 0 votes
- 0 copies
- 2 views
- Added September 2, 2026
Works with
Security analysis
100/100Pro scans all 2 files and shows the line behind each finding
npx -y skills add ronjunevaldoz/kmp-agent-skills --skill kmp-expect-actual --agent claude-codeAre you the author of Kmp Expect Actual?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ronjunevaldoz-kmp-expect-actual)---
name: kmp-expect-actual
description: >
The expect/actual mechanism in Kotlin Multiplatform — when to use it, when NOT
to, and how to do it correctly. Covers: the four categories that genuinely warrant
expect/actual (platform APIs, platform types, performance-critical code, SDK
integration), the common-first rule that prefers a pure `commonMain` implementation
before abstractions, the interface-injection alternative that handles most cases better,
the "actual everywhere" anti-pattern, typealias actual for platform types, @ObjCName
for clean Swift API surfaces, and Kotlin/Native memory considerations. Zero new
dependencies.
license: Apache-2.0
metadata:
author: kmp-agent-skills
last-updated: '2026-09-11'
keywords:
- expect actual
- expect class
- expect fun
- actual typealias
- platform API
- ObjCName
- Swift interop
- Kotlin Native
- KMP platform code
- iOS interop
- platform-specific
- Kotlin Multiplatform
- KMP
- interface injection
- platform type
---
## When to Use This Skill
Use when you need to:
- Write platform-specific code in a KMP project
- Decide between `expect/actual` and an interface with Koin injection
- Expose Kotlin code cleanly to Swift / Objective-C
- Avoid the common "actual everywhere" trap
- Understand why a KMP module isn't compiling on one target
**Trigger keywords:** expect actual, platform-specific code, iOS implementation, actual class,
expect fun, platform API, ObjCName, Swift interop, typealias actual, Kotlin Native memory,
platform dispatcher, platform UUID, FileSystem KMP,
platform-specific implementation, iOS only code, Android only code, platform bridge,
native implementation, different per platform, platform abstraction.
**Freshness rule:** K2 and Kotlin Native change `expect/actual` rules with each Kotlin release —
recheck the Kotlin Multiplatform docs before upgrading past a minor version.
---
## Recommendation First
Default to **implementing the behavior in `commonMain` first**.
If `commonMain` can express it cleanly and portably, keep it there. Do not add an
abstraction just because the code feels reusable; add one only when shared logic still
cannot model the behavior across targets.
When `commonMain` cannot express the behavior, default to **interface in `commonMain` +
Koin injection of platform implementations**.
Why:
- the simplest KMP solution is often a pure shared utility
- interfaces are easier to mock in tests than `actual` declarations
- Koin injection means platform code stays in platform source sets without compiler tricks
- `expect/actual` adds K2 compiler surface area that breaks more often across Kotlin upgrades
Use `expect/actual` only when the platform difference is about the **type** itself (typealias),
a platform **annotation** (`@ObjCName`), or when injection is not available (e.g., top-level
functions needed by Swift interop before Koin is initialized).
---
## The Core Question: expect/actual vs Interface Injection?
Most of the time, **don't use expect/actual**. Use an interface in `commonMain` and inject
the platform implementation via Koin.
```
Should I use expect/actual?
Does the platform difference require a different TYPE signature (not just behavior)?
YES → expect/actual is likely correct
NO → use an interface + Koin injection
Does it wrap a platform SDK that cannot be abstracted at the interface level?
YES → expect/actual is likely correct
NO → use an interface + Koin injection
```
The interface approach is more flexible, testable, and doesn't require a compiler mechanism.
Reserve `expect/actual` for the cases below where interfaces genuinely can't work.
For formatting and other utility work, ask first: "Can this be a pure Kotlin helper in
`commonMain`?" If yes, keep it there. If the helper needs platform-specific behavior, then
split the behavior behind an interface or `expect/actual`.
---
## The Four Categories That Warrant expect/actual
Full content: `references/four-categories.md`.
## When to Use Interface + Injection Instead
For most platform differences, an interface in `:feature:x:api` (or `:core:common`) with
Koin-injected platform implementations is cleaner and more testable:
```kotlin
// commonMain — in :core:common or :feature:x:api
interface Logger {
fun debug(tag: String, message: String)
fun error(tag: String, message: String, throwable: Throwable? = null)
}
// androidMain implementation
class AndroidLogger : Logger {
override fun debug(tag: String, message: String) { Log.d(tag, message) }
override fun error(tag: String, message: String, throwable: Throwable?) {
Log.e(tag, message, throwable)
}
}
// iosMain implementation
class IosLogger : Logger {
override fun debug(tag: String, message: String) { NSLog("[$tag] $message") }
override fun error(tag: String, message: String, throwable: Throwable?) {
NSLog("[ERROR][$tag] $message ${throwable?.message ?: ""}")
}
}
// Koin wiring — in platform-specific modules
// androidMain
val androidModule = module { single<Logger> { AndroidLogger() } }
// iosMain
val iosModule = module { single<Logger> { IosLogger() } }
```
**Advantages over expect/actual:**
- The interface can be faked in tests without `expect/actual` test implementations
- Platform implementations can have different constructor parameters
- New platforms can be added without touching `commonMain`
- The contract is visible in one place
Use **expect/actual** when the thing cannot be constructed without a platform type (Category 1),
when it wraps a platform SDK with no clean interface abstraction (Category 3), or when it's
a primitive function with no state (Category 4).
---
## The "Actual Everywhere" Anti-Pattern
The most common expect/actual mistake: wrapping every piece of functionality in `expect/actual`
because "it might be platform-specific someday":
```kotlin
// ❌ Over-using expect/actual — this is pure business logic with no platform difference
expect class UserValidator {
fun validate(email: String): ValidationResult
}
actual class UserValidator { // Android
actual fun validate(email: String) = ValidationResult(email.contains("@"))
}
actual class UserValidator { // iOS — identical implementation!
actual fun validate(email: String) = ValidationResult(email.contains("@"))
}
```
This adds noise, duplicates code, and requires maintaining N identical implementations.
If all actuals are the same, there's no platform difference — write it in `commonMain`.
**The rule:** if you can write the implementation once in `commonMain` without touching
platform APIs, do that. `expect/actual` is only for genuine platform differences.
---
## @ObjCName: Clean Swift API Surface
Kotlin names don't always translate well to Swift/Objective-C. Use `@ObjCName` to control
the exported name without changing the Kotlin name:
```kotlin
// Without @ObjCName — Swift sees `AuthRepositoryImpl`, Kotlin has sealed interface issues
sealed interface LoginResult {
data class Success(val user: User) : LoginResult
data class Error(val message: String) : LoginResult
}
// With @ObjCName — Swift sees clean names
@ObjCName("LoginResult", exact = true)
sealed interface LoginResult {
@ObjCName("LoginSuccess")
data class Success(val user: User) : LoginResult
@ObjCName("LoginError")
data class Error(val message: String) : LoginResult
}
```
Key `@ObjCName` uses:
- Rename sealed interface subclasses (Swift has no sealed type system)
- Rename companion object members that clash with Swift conventions
- Remove the `Companion` suffix from companion objects
- Clarify generic type names that become unreadable in Swift
```kotlin
// ✓ Companion object without Companion suffix in Swift
class UserRepository {
@ObjCName("shared")
companion object {
val instance = UserRepository()
}
}
// Swift: UserRepository.shared (not UserRepository.Companion.instance)
```
---
## Kotlin/Native Memory: What You Actually Need to Know
Kotlin/Native (iOS/macOS target) uses a different garbage collector than the JVM.
The main practical implication for KMP in 2024+:
**The new memory model (default since Kotlin 1.7.20) removes most frozen-object restrictions.**
You no longer need to worry about `@Frozen` or `ensureNeverFrozen()` in modern KMP code.
What still matters:
**1. MainThread in Kotlin/Native**
iOS requires UI work on the main thread. `Dispatchers.Main` handles this, but if you
accidentally call a `@MainThread` API from a background coroutine you'll get a crash —
not a compile error.
```kotlin
// ✓ Always use withContext(Dispatchers.Main) for UI-touching work on iOS
suspend fun updateUI() = withContext(Dispatchers.Main) {
// safe to call UIKit/AppKit here
}
```
**2. Coroutine cancellation across the Kotlin/Native boundary**
When Swift code holds a reference to a Kotlin coroutine and the Swift side is deallocated,
the coroutine is not automatically cancelled. Explicitly cancel scopes:
```kotlin
class SharedViewModel {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun onDestroy() { scope.cancel() } // call this from Swift deinit
}
```
**3. `@Throws` for Swift error handling**
Kotlin exceptions are not automatically bridged to Swift `Error`. Annotate suspend functions
that throw with `@Throws` to make them Swift-callable:
```kotlin
@Throws(IOException::class, AuthException::class)
suspend fun login(email: String, password: String): User { ... }
```
Swift sees: `try await viewModel.login(email: email, password: password)`
---
## File Structure Convention
```
:core:platform (or inline in :core:common)
src/
commonMain/kotlin/GROUP_ID/core/platform/
SecureStorage.kt ← expect class
PlatformContext.kt ← expect class / typealias
randomUUID.kt ← expect fun
androidMain/kotlin/GROUP_ID/core/platform/
SecureStorage.android.kt ← actual class (Android suffix in filename for clarity)
PlatformContext.android.kt
randomUUID.android.kt
iosMain/kotlin/GROUP_ID/core/platform/
SecureStorage.ios.kt
PlatformContext.ios.kt
randomUUID.ios.kt
jvmMain/kotlin/GROUP_ID/core/platform/
SecureStorage.jvm.kt
randomUUID.jvm.kt
jsMain/kotlin/GROUP_ID/core/platform/
randomUUID.js.kt
wasmJsMain/kotlin/GROUP_ID/core/platform/
randomUUID.wasmJs.kt
```
Suffix convention (`*.android.kt`, `*.ios.kt`) is optional but strongly recommended —
it makes the platform of each file visible in search results and file trees without
opening the file.
### Suffix Scope Boundary
- **`Name.<platform>.kt` is strictly for `actual` declarations** matching a `commonMain` `expect` (e.g. `FrameLoop.desktop.kt` matching `FrameLoop.kt`).
- **Standard `Name.kt` is for platform-exclusive files**: target entry points (`Main.kt`, `MainActivity.kt`), platform DI modules, and platform-only classes (e.g. `WebGpuEngine.kt`) must **not** use the `.<platform>.kt` suffix. Adding a platform suffix to a file with no corresponding `expect` falsely implies a shared abstraction exists in `commonMain`.
- **Coexistence is intentional**: Having `Name.<platform>.kt` for `actual`s alongside standard `Name.kt` for platform-only files across source sets is not a naming split or divergence—it is the clean distinction between `expect`/`actual` pairs and platform-exclusive code.
---
## Verification
1. `./gradlew :core:platform:compileCommonMainKotlinMetadata` — expect declarations compile
2. `./gradlew :core:platform:compileDebugKotlinAndroid` — Android actuals resolve
3. `./gradlew :core:platform:compileKotlinIosSimulatorArm64` — iOS actuals resolve
4. `./gradlew :core:platform:compileKotlinJvm` — Desktop actuals resolve
5. Swift caller: import the framework and verify `@ObjCName`-annotated types appear with correct names in Xcode autocompletion
6. Run a coroutine that crosses the Kotlin/Native boundary and verify it cancels correctly on `deinit`
---
## Common Anti-Patterns
- **JVM-only utility in `commonMain`** — `String.format`, `DecimalFormat`, `SimpleDateFormat`,
or any other JVM-only formatter in shared code; keep the shared API in `commonMain`, but
move the JVM-specific implementation to `jvmMain` or hide it behind a platform adapter.
- **`expect` class with state** — if an `expect class` has mutable state, it cannot be tested in `commonTest` without a real platform target. Extract the state to a shared type and keep the `actual` stateless.
- **Wrapping a single function** — `expect fun getPlatformName()` is fine; `expect class PlatformNameProvider` to wrap it is overcomplicated. Use `expect fun` directly.
- **`expect`/`actual` for dependency injection** — inject the platform dependency through Koin instead; reserve `expect`/`actual` for platform capabilities that are not injectable objects.
- **`actual` in a shared module** — `actual` declarations must live in platform source sets (`androidMain`, `iosMain`); putting them in `commonMain` defeats the purpose.
- **Not annotating with `@ObjCName`** — every `expect` declaration that surfaces to Swift should carry `@ObjCName` to control the generated Swift name. Load `kmp-xcframework-spm` for guidance.
- **Leaking a raw platform type into a `commonMain` function signature** — `fun save(context: Context)` or `fun present(vc: UIViewController)` written directly in shared code forces every other target to either fake the type or never compile. This is exactly what `expect class PlatformContext` above exists to prevent — the platform type never appears in a `commonMain` signature at all, only its `expect`-declared abstraction does.
- **Copy-pasted `actual` leaves a sibling's vocabulary behind** — real, evidence-backed pattern: a second platform's `actual` implementation started as a copy of the first, and some fields never got redesigned for the new platform's own shape. Generalized example (not the domain it was found in — the shape is identical regardless of platform pair):
```kotlin
// androidMain — actual for a push-notification backend
actual class PushClient actual constructor() {
actual val token: String? get() = fcmToken
val fcmToken: String? = null // real field, used
}
// iosMain — actual copy-pasted from androidMain, never redesigned
actual class PushClient actual constructor() {
actual val token: String? get() = apnsToken
val fcmToken: String? = null // ❌ leftover from the Android actual — unused, meaningless on iOS
val apnsToken: String? = null // the real field
}
```
`fcmToken` on the iOS `actual` is dead weight that reads as "this platform also has an FCM token," which is false — it's a copy-paste leftover, not a real field. The fix isn't subtle: delete fields that don't belong to the platform being implemented, don't keep them "for symmetry." Each `actual` should look like it was designed for its own platform, not edited from the other one.
---
## Testing
```kotlin
// commonTest — test pure logic that wraps platform capabilities via an interface
interface PlatformClock {
fun nowMillis(): Long
}
class FakePlatformClock(private val now: Long = 1_000L) : PlatformClock {
override fun nowMillis() = now
}
@Test fun `elapsed time calculation uses injected clock`() {
val clock = FakePlatformClock(now = 5_000L)
val elapsed = clock.nowMillis() - 1_000L
assertEquals(4_000L, elapsed)
}
// For expect/actual functions with no side effects, test the actual directly:
@Test fun `platform name is non-empty`() {
assertTrue(getPlatformName().isNotBlank())
}
// androidUnitTest — verify the Android actual behaves correctly
@Test fun `android platform name contains Android`() {
assertTrue(getPlatformName().contains("Android", ignoreCase = true))
}
// If the actual wraps a platform API, extract logic behind an interface so it can
// be faked in commonTest — this is the preferred pattern over testing the actual directly.
```
> Platform-specific actuals that wrap impure APIs (camera, location, keychain) should be
> exercised via instrumented tests or XCTest on their respective platforms. Use
> `FakeXxx` in `commonTest` for all ViewModel and domain-layer coverage.
---
## References
Full implementation content lives in `references/four-categories.md`. Load it when
working on "The Four Categories That Warrant expect/actual".
---
## Related Skills
- `kmp-dependency-injection` — preferred alternative to expect/actual for most platform abstractions
- `kmp-feature-scaffold` — module structure where `actual` declarations live in platform source sets
- `kmp-xcframework-spm` — `@ObjCName` annotations from this skill affect the Swift API surface
- `kmp-network-layer` — platform dispatcher and engine selection are common expect/actual use cases
---
## Output Style
When asked about expect/actual or platform-specific code, respond in this order:
1. recommendation (interface + injection vs expect/actual — pick the right tool)
2. code snippet (the smallest valid expect/actual or interface pair)
3. why that approach is preferred
4. main alternative
Lead with the decision rule. Keep snippets small — one `expect`/`actual` pair or one interface, not both.
---
## Changelog
| Date | Change |
|---|---|
| 2026-09-11 | Added "Suffix Scope Boundary" under File Structure Convention — clarify that `Name.<platform>.kt` is reserved strictly for `actual` declarations matching an `expect`, while platform-exclusive files (`Main.kt`, platform DI, engine implementations) use standard `Name.kt`. Coexistence of both is the intended design, not a naming divergence. |
| 2026-08-24 | Added "Copy-pasted `actual` leaves a sibling's vocabulary behind" — real finding from reviewing a KMP native-binding project: a second platform's `actual` implementation was copy-pasted from its sibling and left unused fields shaped for the wrong platform. Generalized to a push-notification-client example, not the domain it was found in. Cross-referenced from `kmp-jni-pro`'s new Phase 0f. |
| 2026-08-04 | Split "The Four Categories That Warrant expect/actual" out of SKILL.md into `references/four-categories.md`, leaving a pointer stub plus a new References section. SKILL.md drops from 507 to 399 lines, clearing the agentskills.io 500-line recommendation. No content removed, only relocated. Part of the same backlog cleanup as the other 19 skills fixed alongside it (KI-008). |
| 2026-07-20 | Added an explicit "leaking a raw platform type into a commonMain function signature" anti-pattern — a real, general KMP anti-pattern (not library-specific), named explicitly even though the skill's own `expect class PlatformContext` example already teaches the correct pattern. |
| 2026-06-06 | Initial release. |
Files in this skill
- SKILL.md
- references/four-categories.md
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.