skip to content

What constraints and pitfalls apply to `expect`/`actual` functions around `suspend`, return-type variance across platforms, and evolving the API over time?

level: principalimportance: nice to knowfreq 18%

answer

  1. suspend on expect ⇒ suspend on every actual
  2. Return type must be nameable in commonMain
  3. Convert platform types inside the actual
  4. Signature change ripples to all targets at once
  5. Build all targets in CI; common-green ≠ all-green

basics

~20 s

If the expect function is suspend, every actual must be suspend too. The common return type must be something all platforms can satisfy. Changing an expect signature forces updating every actual, so design these boundaries carefully.

solid answer

~50 s

Modifiers like `suspend` are part of the contract: an `expect suspend fun` requires every `actual` to be `suspend` as well — you cannot make one platform's actual blocking and another suspending at the signature level. The **common return type must be expressible in commonMain**, so you can't directly return a platform-only type (e.g., `NSData`, `java.io.File`); you surface a common type (`ByteArray`, your own class) and convert inside each actual. Because the compiler enforces one actual per target, **any signature change to an expect ripples to all targets simultaneously** — a binary/source-compat hazard in published KMP libraries, where adding a parameter or changing a default breaks every consumer's actuals. Mitigations: keep expect/actual surfaces tiny and stable, prefer additive overloads, push volatility behind common interfaces, and treat each expect as a long-lived public contract. Also remember the per-target check means a green common build can still fail on a platform whose actual wasn't updated.

go deeper

for a junior

Likely unaware that suspend/return-type/evolution constraints exist here.

for a middle

Knows suspend must match and return types must be common, but underestimates evolution cost.

for a senior

Handles type conversion inside actuals and understands per-target compile checks.

for a principal

Designs stable, minimal expect/actual seams, manages change-amplification and binary compatibility, and enforces all-target CI.

## Modifiers are part of the contract Function modifiers on the `expect` must be honored by every `actual`: - `suspend`: `expect suspend fun fetch(): ByteArray` forces `actual suspend fun fetch()` everywhere. You cannot mix suspending and non-suspending actuals — the signatures wouldn't match. - `inline`, `infix`, `operator`: likewise consistent across actuals. - Visibility may only widen on the actual, never narrow. ```kotlin // commonMain expect suspend fun readAll(path: String): ByteArray // jvmMain actual suspend fun readAll(path: String): ByteArray = withContext(Dispatchers.IO) { java.io.File(path).readBytes() } // iosMain — also suspend, converts NSData -> ByteArray internally actual suspend fun readAll(path: String): ByteArray { /* NSData -> ByteArray */ TODO() } ``` ## Return type must live in common The declared return type is part of the **common** signature, so it must be a type `commonMain` can name. You cannot write `expect fun open(): java.io.File` — `java.io.File` doesn't exist on iOS/JS. Instead surface a **common** abstraction (`ByteArray`, an `expect class`, or your own interface) and convert from the platform type **inside** each actual. This keeps the common API platform-agnostic. ## Evolution and compatibility hazards The "exactly one actual per expect, per target" guarantee is great for correctness but creates **change amplification**: - Adding a parameter, changing a type, or altering a default on an `expect` **breaks every actual** until each is updated. - For a **published KMP library**, this is a binary/source compatibility concern: consumers (or your own downstream targets) must update all actuals in lockstep. There's no per-platform graceful degradation. - A green `commonMain`/JVM build can mask a broken `iosX64` actual because the check is **per target compilation**. ## Design guidance for large/long-lived code - **Keep expect/actual surfaces minimal and leaf-level** — the smaller the contract, the cheaper to evolve. - **Prefer additive changes**: new overloads instead of mutating existing signatures. - **Hide volatility behind common interfaces**; let only the stable leaf be expect/actual. - **Build all targets in CI** so a missing/mismatched actual fails fast, not just the common build. - Treat each `expect fun` as a **public, long-lived contract**, even if it's `internal`, because every target depends on its exact shape. ## Keywords/APIs in play `suspend`, `withContext`, `Dispatchers.IO`, `inline`, `operator`, `expect class` (for surfacing common types), `actual` per leaf/intermediate set.

  • Can one platform's actual be blocking while another's is suspending?
    Not at the signature level: if the expect is `suspend`, all actuals must be `suspend`. You can implement a non-suspending platform by simply not suspending inside the body, but the modifier stays.
  • How do you expose a platform-only type like NSData through expect/actual?
    You don't return it directly. Surface a common type (e.g., ByteArray or your own class) in the signature and convert from NSData inside the iOS actual.
  • Why is a green common build insufficient before release?
    expect/actual is checked per target, so a mismatched or missing actual on, say, iosArm64 only fails that target's build; you must compile every target.

An expect signature is a treaty every nation (target) signed; amending one clause requires every nation to re-ratify simultaneously.

saying these in an interview costs you the question

  • Returning a platform-only type from an expect signature
  • Assuming you can mix suspend and non-suspend actuals
  • Treating an expect signature as cheap to change across a target matrix
  • Releasing a KMP library without building every target in CI
  • Putting a large, volatile API directly behind expect/actual

context