A sealed hierarchy exports as an open class, so Swift cannot switch on it
Exhaustiveness is why you used a sealed type, and it is exactly what does not cross.
TL;DR
- A
LoginResultsealed interface with four cases exported to Swift as four unrelated classes plus a base, soswitchrequired adefault:branch — and that branch silently absorbed a fifth case added six weeks later. - Objective-C has no sum type. The Kotlin/Native exporter maps a sealed hierarchy onto an open class hierarchy, which is the closest thing Obj-C has and carries none of the exhaustiveness.
- The fix is a projection: a flat enum discriminator plus accessors, generated beside the sealed type so adding a case is a Kotlin compile error on both sides.
- What reading on buys: the projection's shape, why
whenon the Kotlin side must stay exhaustive for it to work, and what it costs at the call site.
The case this post is about
A login use case, modelled the way Kotlin wants it:
// commonMain/kotlin/auth/LoginResult.kt
sealed interface LoginResult {
data class Success(val session: Session) : LoginResult
data object WrongCredentials : LoginResult
data class AccountLocked(val until: Instant) : LoginResult
data object NetworkUnavailable : LoginResult
}
Consumed in Kotlin, exhaustively, with no else:
// androidMain — the compiler enforces the four
when (val r = login(email, password)) {
is LoginResult.Success -> goHome(r.session)
LoginResult.WrongCredentials -> showFieldError()
is LoginResult.AccountLocked -> showLockedUntil(r.until)
LoginResult.NetworkUnavailable -> showOfflineBanner()
}
And consumed in Swift, which is where it stops being the same type:
// iosApp/Sources/LoginViewModel.swift
switch result {
case let s as LoginResultSuccess: goHome(s.session)
case is LoginResultWrongCredentials: showFieldError()
case let l as LoginResultAccountLocked: showLockedUntil(l.until)
case is LoginResultNetworkUnavailable: showOfflineBanner()
default: showGenericError() // ← required
}
That default: is not a style choice. Swift will not compile the switch without it.
The symptom
Six weeks later a fifth case was added, for a server-side migration gate:
data class MustUpgrade(val minVersion: String) : LoginResult
The Android build broke immediately, in four places, which is exactly what a sealed type is for:
e: 'when' expression must be exhaustive, add necessary 'is MustUpgrade' branch or 'else' branch
androidMain/kotlin/LoginScreen.kt:48
The iOS build did not break. It compiled, shipped, and showed users on an old binary a generic error with no indication that an upgrade was required — for eleven days, until a support ticket described the symptom well enough to connect it.
The Swift compiler had nothing to complain about. The switch was total before and total after,
because default: makes every switch total.

Why the obvious fix failed
The first attempt was to make the Swift switch exhaustive by removing default::
switch result {
case let s as LoginResultSuccess: …
// … four cases, no default
}
error: switch must be exhaustive
note: add missing case: 'default'
Swift's exhaustiveness checking works on enums — a closed set of cases the compiler knows. It
does not work on class hierarchies, because a class hierarchy is open by construction: any module
could subclass LoginResult tomorrow, so no finite list of as patterns can be proven complete.
The second attempt was @objc enum, which would give Swift a real enum:
// does not compile
@objc sealed interface LoginResult
@objc is not available in Kotlin/Native's export path, and a Kotlin enum class cannot carry
per-case payloads — AccountLocked needs an Instant and Success needs a Session. The two
requirements (a closed set, and data per case) are exactly what a sealed class gives you and
exactly what does not cross.
The third attempt was a test, and it is the one that nearly shipped as the whole answer: add a Swift test asserting the five cases are handled. It passes, and it is a test somebody has to remember to extend when a sixth case arrives — which is the same failure mode one level removed.
The actual mechanism
The export goes through Objective-C, and the mapping is forced by what Obj-C can express.
Obj-C has no sum type. There is no construct meaning "exactly one of these, each with its own payload". The nearest available shape is a base class with subclasses, so that is what the exporter emits:
// generated header, simplified
__attribute__((objc_subclassing_restricted))
@interface SharedLoginResult : SharedBase // the sealed base
@end
@interface SharedLoginResultSuccess : SharedLoginResult
@property (readonly) SharedSession *session;
@end
@interface SharedLoginResultWrongCredentials : SharedLoginResult
@end
// … and so on
objc_subclassing_restricted is present and does not help. It stops Swift code from adding
a subclass, which is genuinely useful — and Swift's exhaustiveness checker does not consult it.
The checker has one rule for class patterns: a switch over a class type is never provably total.
So the sealedness survives the export as a runtime property and is lost as a compile-time one. Every value is one of the four classes; nothing can tell the Swift compiler that the list is finished.
And the consequence compounds in one specific way worth naming: the Kotlin side's exhaustiveness is what makes the absence on the Swift side invisible. Adding a case breaks the Kotlin build loudly, so the author fixes four Kotlin call sites, sees green, and has no signal at all that a fifth consumer exists in a different language with a different totality rule. The strongest guarantee in the codebase is the reason the weakest one went unnoticed.

The fix
Export a projection: a flat enum naming the case, plus nullable accessors for the payloads, generated from the same sealed type so the two cannot drift.
// commonMain/kotlin/auth/LoginResultView.kt
//
// The exported surface for Swift. A flat enum gives Swift real exhaustiveness; the nullable
// accessors carry the payloads the enum cannot. Kept beside the sealed type ON PURPOSE —
// a projection in another module is one that gets forgotten when a case is added.
enum class LoginOutcome {
Success, WrongCredentials, AccountLocked, NetworkUnavailable, MustUpgrade
}
class LoginResultView internal constructor(
val outcome: LoginOutcome,
val session: Session? = null,
val lockedUntil: Instant? = null,
val minVersion: String? = null,
)
/**
* The single conversion point. The `when` here is EXHAUSTIVE and has no `else`, which is the
* load-bearing property of the whole design: adding a sealed case makes THIS function a Kotlin
* compile error, and the error is in the one place that knows about the Swift consumer.
*
* An `else -> LoginResultView(Unknown)` branch would compile forever and reintroduce exactly
* the silent-default bug this exists to remove, one layer down.
*/
fun LoginResult.toView(): LoginResultView = when (this) {
is LoginResult.Success -> LoginResultView(LoginOutcome.Success, session = session)
LoginResult.WrongCredentials -> LoginResultView(LoginOutcome.WrongCredentials)
is LoginResult.AccountLocked -> LoginResultView(LoginOutcome.AccountLocked, lockedUntil = until)
LoginResult.NetworkUnavailable -> LoginResultView(LoginOutcome.NetworkUnavailable)
is LoginResult.MustUpgrade -> LoginResultView(LoginOutcome.MustUpgrade, minVersion = minVersion)
}
Swift now switches on an enum, and the compiler enforces it:
// iosApp/Sources/LoginViewModel.swift — no default, and none is accepted
switch view.outcome {
case .success: goHome(view.session!)
case .wrongCredentials: showFieldError()
case .accountLocked: showLockedUntil(view.lockedUntil!)
case .networkUnavailable: showOfflineBanner()
case .mustUpgrade: showUpgradePrompt(min: view.minVersion!)
}
And the output it produced — the Swift build, after the sixth case was added to the sealed type
and toView() was updated but the Swift switch was not:
$ ./gradlew :shared:linkDebugFrameworkIosArm64 && xcodebuild -scheme iosApp build
error: switch must be exhaustive
note: add missing case: '.rateLimited'
LoginViewModel.swift:31:9
Which is the error that did not exist for eleven days.
The force-unwraps are the honest cost. view.session! is correct by the projection's construction
and the compiler cannot see it, so the type system has been traded for a crash on a shape the
converter cannot produce. Two alternatives, both worse for this case: a Swift-side wrapper enum
with associated values (hand-written, so it drifts — which is the original problem), or one view
class per case (which gives Swift classes again, so no exhaustiveness). We kept the unwraps and
wrote a test that constructs every outcome and reads its payloads.
The sub-case the flat enum cannot express. A nested sealed hierarchy — the shape you reach for when one case has its own variants — flattens badly:
sealed interface LoginResult {
sealed interface Rejected : LoginResult {
data object WrongCredentials : Rejected
data class Locked(val until: Instant) : Rejected
data class RateLimited(val retryAfter: Duration) : Rejected
}
data class Success(val session: Session) : LoginResult
}
Kotlin callers can match at either depth — is Rejected to show one banner, or the specific
variant to vary the copy. A single flat enum forces a choice: either four leaf values, losing the
"any rejection" grouping, or two values, losing the detail. We took the leaves and added a computed
isRejected: Boolean to the view, which recovers the grouping as a predicate rather than as a
type. It works and it is a downgrade: Swift can no longer express "handle every rejection the same
way" as one exhaustive branch, so that intent lives in an if the compiler does not check.
Two levels is where I would stop. A three-level hierarchy projected flat produces an enum whose
case names encode the path (rejectedLockedTemporary), and at that point the projection is harder
to read than the thing it exports.
How we proved it
That the export really is an open hierarchy, rather than this being a Swift-side mistake — read from the generated header rather than inferred:
$ grep -A1 'interface SharedLoginResult' \
shared/build/bin/iosArm64/debugFramework/shared.framework/Headers/shared.h | head -8
__attribute__((objc_subclassing_restricted))
@interface SharedLoginResult : SharedBase
@interface SharedLoginResultSuccess : SharedLoginResult
@interface SharedLoginResultWrongCredentials : SharedLoginResult
A base and four subclasses. No enum anywhere in the emitted surface.
That the projection's exhaustiveness is real on both sides. The test is to add a case and confirm both builds fail, which is the property the original design had on one side only:
# add `data object RateLimited : LoginResult`, change nothing else
$ ./gradlew :shared:compileKotlinIosArm64
e: 'when' expression must be exhaustive, add necessary 'is RateLimited' branch
commonMain/kotlin/auth/LoginResultView.kt:28 ← the converter
# add it to the converter, leave Swift alone
$ xcodebuild -scheme iosApp build
error: switch must be exhaustive, add missing case: '.rateLimited'
Two failures, in sequence, and neither is skippable. Before the projection the second one did not occur at all.
That an else in the converter reintroduces the bug. The gate for this ships as a
deliberately-broken fixture — the same converter with a catch-all — and it must fail the check that
forbids it:
$ ./gradlew :shared:detekt
ForbiddenElseInProjection: commonMain/kotlin/auth/LoginResultView.kt:28
`else` in a *View projection defeats the compile-time coupling it exists to create.
Enumerate the cases; a new one SHOULD break this file.
A custom rule, 20 lines, and it is the thing that keeps the design honest after the author who built it has moved on.
What I cannot tell you is whether the projection is worth it below some number of cases. Four cases and two consumers made it clearly worth it here; a two-case result consumed only by Kotlin would not justify the extra type. The decision input is whether a Swift consumer exists at all, and that is a yes/no rather than a threshold.

What this generalizes to
An FFI narrows what you can express, and the properties it drops are often the ones you chose the construct for. A sealed class's value is a compile-time guarantee, so a boundary that cannot carry compile-time guarantees drops precisely its value while preserving its runtime behaviour — which is the worst combination, because everything keeps working. The reliable move is to find a shape the boundary can express (a flat enum crosses perfectly) and to make the conversion to it a place the compiler will break when the source type changes. The conversion is where the guarantee is reconstructed, so it must have no escape hatch.
Related
- Your default arguments do not exist in Swift — another property that compiles on both sides and means something different at the boundary.
- A Kotlin exception reaches Swift as an untyped NSError — the same erasure applied to failures, and why returning a result beats improving the throw.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".