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.
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 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.
Related
- Your suspend function becomes a callback in Swift, and cancellation does not survive — the same bridge, for concurrency
- expect/actual is not an interface, and the difference shows up at link time — compile-time vs runtime resolution
- A DI module that compiles on every target and only works on one — when the compiler stops checking for you
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".