Engineering

Your Kotlin class becomes an Objective-C class, which is why the name is wrong

Obj-C has no namespaces. Everything exported is prefixed and flattened, and each Kotlin idiom loses something specific.

MobileByteSensei 8 min read
kmpiosinterop

TL;DR

Your shared module does not reach Swift as Kotlin. It reaches Swift as Objective-C, and Objective-C has no namespaces, no generics worth the name, no sealed types, and no default arguments.

So com.example.search.SearchState.Loading arrives as SharedSearchStateLoading. The when that was exhaustive in Kotlin becomes a switch Swift demands a default on. copy() is gone. Default arguments are gone. None of this is a bug — it is the cost of the only interop Objective-C can express.

You cannot remove the translation. You can design the exported surface so the lossy parts are not on it.

The case this post is about

A search feature with the state modelling everyone writes in Kotlin:

// commonMain
sealed interface SearchState {
    data object Idle : SearchState
    data object Loading : SearchState
    data class Success(val results: List<Result>, val fromCache: Boolean = false) : SearchState
    data class Failed(val reason: Reason) : SearchState
}

data class Result(val id: String, val title: String, val score: Double)

Clean, exhaustive, idiomatic. Then an iOS engineer tries to render it.

The symptom

Three complaints, in the order they arrived.

The names. Autocomplete in Xcode offers:

SharedSearchState
SharedSearchStateIdle
SharedSearchStateLoading
SharedSearchStateSuccess
SharedSearchStateFailed
SharedResult

Every type carries the framework prefix, and the nesting that made SearchState.Loading readable is flattened into the name itself.

The switch is not exhaustive. This is the one that causes real friction:

switch state {
case is SharedSearchStateIdle:    …
case is SharedSearchStateLoading: …
case let s as SharedSearchStateSuccess: …
case let f as SharedSearchStateFailed: …
}
// error: switch must be exhaustive
// → add `default:` — and now adding a Kotlin case compiles silently in Swift

The exhaustiveness that is the entire reason for a sealed hierarchy does not cross. Swift sees unrelated Obj-C classes with a common superclass, so it cannot know the set is closed — and the default: you are forced to write is exactly what will swallow a new state later.

copy() and defaults are missing. No result.copy(title: …). And SearchState.Success(results) does not compile — the fromCache default does not exist in Obj-C, so Swift must pass every parameter:

SharedSearchStateSuccess(results: rs, fromCache: false)   // required, always

Why the obvious fix failed

"Rename the framework to shorten the prefix." Helps readability and nothing else. The exhaustiveness and the defaults are unaffected, and I spent a build cycle discovering that the cosmetic complaint was the only one a rename addresses.

"@ObjCName the classes." Genuinely useful — @ObjCName("SearchSuccess") gives you the Swift-side name you want. Still cosmetic with respect to the two real problems, and it is annotation-per-declaration, so it is maintenance.

"Flatten the sealed hierarchy into one class with a type enum." This does restore exhaustiveness in Swift, because Kotlin enums export as @objc enums that Swift can switch exhaustively. It also throws away the type safety on the Kotlin side — Success and Failed both become one class with nullable fields, so shared code loses the compiler check that was the reason for the hierarchy. Optimising the shared model for the weaker consumer is the wrong trade.

"Expose the sealed type and let iOS deal with default:." What we did first. It works until a fifth state is added, at which point Android's when fails to compile and iOS silently takes the default branch. The one language that needed telling is the one that was not told.

The actual mechanism

The export is Kotlin → Objective-C → Swift, and the middle step is the constraint. Every loss follows from something Obj-C lacks.

No namespaces, so names are mangled. Obj-C has a single flat global namespace of class names, historically disambiguated with two- or three-letter prefixes. The compiler therefore prefixes every exported class with the framework name and flattens nested classes into the name. There is no module system to preserve.

No sealed types, so no exhaustiveness. A sealed hierarchy exports as a base class plus subclasses — an open class hierarchy as far as Obj-C is concerned. Swift's exhaustiveness checking works for Swift enums and for @objc enums, not for class hierarchies, because any Obj-C class can be subclassed from anywhere.

No default arguments. Obj-C selectors are fixed. One Kotlin function with three defaults is one Obj-C method with all parameters required — not four overloads.

data class generates Kotlin-only members. copy(), componentN() and the generated toString are Kotlin constructs; equals/hashCode do survive, mapped to isEqual:/hash.

Generics are mostly erased. List<Result> becomes NSArray<Result *> which Swift sees usefully, but Flow<T>, suspend-returning generics and variance come across as much weaker types or as Any.

The export path from Kotlin through Objective-C to Swift, with what each Kotlin idiom loses at the middle step: nested names flattened and prefixed because Obj-C has one global namespace, sealed exhaustiveness lost because an exported hierarchy is an open class hierarchy, default arguments lost because selectors are fixed, and data-class copy lost because it is a Kotlin-only generated member

The rule that matters:

The exported surface is an Objective-C API, whatever it looks like in Kotlin. Design it as one. Keep the Kotlin idioms you want internal, and put something Obj-C can express at the boundary.

The fix

Keep the sealed hierarchy — it is correct, and shared code benefits from it. Add a thin exported projection that Obj-C can express, and let the iOS side switch on something closed.

// commonMain — internal model unchanged, still sealed, still exhaustive
sealed interface SearchState { … }

// commonMain — the exported projection
enum class SearchStateKind { Idle, Loading, Success, Failed }   // exports as @objc enum

class SearchStateView internal constructor(
    val kind: SearchStateKind,
    val results: List<Result>,
    val fromCache: Boolean,
    val failureReason: String?,
)

fun SearchState.asView(): SearchStateView = when (this) {
    SearchState.Idle    -> SearchStateView(Idle,    emptyList(), false, null)
    SearchState.Loading -> SearchStateView(Loading, emptyList(), false, null)
    is SearchState.Success -> SearchStateView(Success, results, fromCache, null)
    is SearchState.Failed  -> SearchStateView(Failed,  emptyList(), false, reason.message)
}   // ← exhaustive. Adding a state breaks THIS, which is the point.

Swift now gets exhaustiveness, with no default::

switch state.kind {
case .idle:    EmptyView()
case .loading: ProgressView()
case .success: ResultList(state.results, cached: state.fromCache)
case .failed:  ErrorView(state.failureReason ?? "")
}   // adding a Kotlin case → this stops compiling. Which is what we wanted.

What this actually buys is worth being precise about, because the mapper looks like boilerplate:

The compiler now tells both platforms. A new SearchState subtype fails the when in asView(). That is a Kotlin compile error on a function that exists specifically to serve iOS — so the iOS consumer cannot be forgotten, and the new enum case then fails the Swift switch. The mapper is the thing converting "iOS silently took default" into "the build stops".

Nullable fields are the cost, and they are contained. SearchStateView is less precise than the sealed type — failureReason is nullable in every non-failed case. That imprecision lives in one projection class rather than in the model, and the kind tells the consumer which fields are meaningful.

For defaults and copy(), the honest answer is to provide explicitly what Kotlin was generating:

// commonMain — one overload per shape iOS actually needs, not every combination
fun searchSuccess(results: List<Result>) = SearchState.Success(results, fromCache = false)
fun searchSuccessFromCache(results: List<Result>) = SearchState.Success(results, fromCache = true)

And @ObjCName where the mangled name is genuinely painful at a call site iOS uses constantly:

@ObjCName("SearchState")
class SearchStateView internal constructor(…)

A note on the alternative: SKIE generates most of this — it exposes sealed hierarchies as exhaustively-switchable Swift enums, Flow as AsyncSequence, and preserves default arguments. If your exported surface is large, adopting it is less work than hand-writing projections. The mechanism above is what it is doing on your behalf, and what you need when the plugin is not an option.

How we proved it

The claim is about compile-time behaviour, so the test is a compile, run twice.

Add a fifth state and record what fails:

data class PartiallyLoaded(val results: List<Result>, val more: Int) : SearchState
BEFORE the projection
  ./gradlew :shared:compileKotlinIosArm64     FAILED  (when in ViewModel)
  xcodebuild                                  SUCCEEDED  ← silently took `default:`

AFTER the projection
  ./gradlew :shared:compileKotlinIosArm64     FAILED  (when in asView)
  xcodebuild                                  FAILED  ← switch must be exhaustive

The second row is the whole value. The Swift build failing is the behaviour we were missing, and it is now a mechanical consequence of adding a Kotlin case.

The names are worth verifying directly rather than trusting, because the generated header is the actual contract:

$ grep -E '^@interface' shared.framework/Headers/shared.h | head
@interface SharedBase : NSObject
@interface SharedSearchState : SharedBase
@interface SharedSearchStateIdle : SharedSearchState
@interface SharedSearchStateSuccess : SharedSearchState
@interface SharedResult : SharedBase

Reading that header once is the fastest way to understand what iOS sees — it is the API, and it is sitting in your build output.

One limit: this covers the state-out direction. Commands going in (functions iOS calls) have their own losses — default arguments, suspend functions, generics — and want the same treatment: an explicitly designed Obj-C-expressible surface rather than whatever Kotlin happens to generate.

What this generalizes to

Your public API is whatever the weakest layer in the chain can express. Not your language's features — the intersection of every layer the call passes through. For KMP on iOS that layer is Objective-C, and the question for any exported declaration is "can Obj-C say this?"

Exhaustiveness is a property of a closed set, and closedness rarely survives a boundary. Sealed types, Rust enums, discriminated unions — all closed in one language and open after serialisation or FFI. If you want the other side to be told about a new case, something on the boundary must break when you add it.

Prefer a projection to a compromise. The tempting move is to weaken the shared model so it exports cleanly. The better one is to keep the model strong and add a translation whose exhaustiveness check is itself the alarm. The projection is small, boring, and it is the thing that makes the compiler enforce your contract.

Read the generated artifact. The Obj-C header is the real interface, generated on every build, and almost nobody opens it. Whatever the boundary is in your system — a header, an OpenAPI document, a .proto, a .d.ts — reading the generated form answers in a minute what guessing answers wrongly.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS