skip to content

Why can code in iosMain call POSIX or Apple Foundation APIs while commonMain cannot, and how does the hierarchy determine API visibility?

level: seniorimportance: should knowfreq 40%

answer

  1. Visibility = intersection of dependent targets' APIs
  2. commonMain sees only universal stdlib
  3. iosMain unlocks POSIX/Foundation without expect/actual
  4. Narrower target set = bigger API surface
  5. Compiler checks each set against combined metadata

basics

~20 s

A source set can only use APIs that exist on every target below it. commonMain must compile for all targets, so it sees only the truly universal API. iosMain compiles only for iOS targets, so it can use APIs all iOS targets share, like POSIX or Foundation.

solid answer

~50 s

API visibility in a source set is the **intersection** of the platform APIs of all targets that depend on it. `commonMain` is shared by every target, so the only APIs visible there are those present on *all* of them — essentially the Kotlin stdlib's common surface. An intermediate set like `iosMain` is shared only by iOS targets, so its visible surface is the intersection of *those* targets — which includes `platform.posix.*`, Apple `platform.Foundation.*` cinterop bindings, and other native-only APIs. The Kotlin compiler compiles each source set against the metadata of exactly the targets that `dependsOn` it; an API only resolves if it is available on every one of them. That is why moving code from `commonMain` down into `iosMain` 'unlocks' POSIX without any `expect/actual` — you simply narrowed the target set. Pushing too much into common forces you to abstract via `expect/actual` instead.

code

kotlin · 14 lines
kotlin
// commonMain: must abstract platform specifics
expect fun currentTimeMicros(): Long

// iosMain: can call POSIX directly, no expect needed for internal helpers
import platform.posix.gettimeofday
import platform.posix.timeval
import kotlinx.cinterop.*

@OptIn(ExperimentalForeignApi::class)
actual fun currentTimeMicros(): Long = memScoped {
    val tv = alloc<timeval>()
    gettimeofday(tv.ptr, null)
    tv.tv_sec * 1_000_000L + tv.tv_usec
}

go deeper

for a junior

Knows iosMain can use iOS APIs and commonMain cannot, without the intersection theory.

for a middle

Explains commonMain restriction and that narrowing targets unlocks more APIs.

for a senior

Articulates the intersection rule and how the metadata compiler enforces visibility per set.

for a principal

Uses visibility rules to design a sharing strategy balancing intermediate sets vs. expect/actual at scale.

## The core rule: visibility = intersection of targets Every source set is compiled for the set of targets that (transitively) `dependsOn` it. The APIs you can reference in that source set are the **intersection** of the platform APIs across those targets — only what *all of them* provide. - `commonMain` → every target → intersection is the **common stdlib surface** only. No `java.*`, no `platform.posix`, no Foundation. - `iosMain` → `iosArm64`, `iosX64`, `iosSimulatorArm64` → intersection includes **Kotlin/Native** APIs and **Apple** cinterop bindings (`platform.posix.*`, `platform.Foundation.NSDate`, `platform.darwin.*`). - `jvmAndAndroidMain` (custom) → JVM + Android → intersection includes `java.*` and `kotlin.jvm.*`. ## Why commonMain is restricted `commonMain` code must **compile for JS, Wasm, JVM, and Native alike**. There is no `File`, no POSIX, no `Thread` that is universally meaningful, so the compiler refuses references to platform-specific symbols. To use them from common code you must declare an **`expect`** and provide per-target **`actual`** implementations. ## Why intermediate sets relax this Because an intermediate set is compiled for a **narrower** target set, the intersection of available APIs **grows**. Move code from `commonMain` into `iosMain` and you can suddenly call POSIX directly — no `expect/actual` needed — because every target compiling that code has POSIX. ```kotlin // in src/iosMain/kotlin import platform.posix.gettimeofday import platform.posix.timeval import kotlinx.cinterop.* @OptIn(ExperimentalForeignApi::class) fun nowMicros(): Long = memScoped { val tv = alloc<timeval>() gettimeofday(tv.ptr, null) tv.tv_sec * 1_000_000L + tv.tv_usec } // This compiles in iosMain but NOT in commonMain — POSIX is iOS-visible only. ``` ## How the compiler enforces it The Kotlin metadata compiler type-checks each shared source set against the **combined metadata** of its dependent targets. A symbol resolves only if present in *all* of them; otherwise you get an 'unresolved reference' in that source set even though it resolves fine one level down. ## Design implication - Push code **as high as it correctly compiles** for maximal sharing. - When an API is missing at a level, you choose: (a) move the code into a lower intermediate set, or (b) keep it common and bridge with `expect/actual`. - Over-pushing into `commonMain` breeds excessive `expect/actual`; under-pushing duplicates code in leaves. Intermediate sets are the sweet spot.

  • If an API is on iOS but not macOS, can appleMain use it?
    No. appleMain is shared by iOS and macOS, so its visibility is the intersection; an iOS-only API resolves only in iosMain or lower, not in appleMain.
  • Does moving code into iosMain remove the need for expect/actual entirely?
    For APIs shared by all iOS targets, yes — no expect/actual needed there. But anything commonMain still calls across all platforms must remain expect/actual.

Like permissions on a shared drive: a folder everyone can open shows only files everyone is allowed to see; a folder limited to one team shows that team's extra files.

saying these in an interview costs you the question

  • Saying commonMain can call POSIX or java.* directly
  • Believing visibility is the union, not intersection, of targets
  • Thinking intermediate sets need expect/actual for subset-common APIs
  • Not knowing the compiler checks against combined target metadata

context