Engineering

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.

MobileByteSensei 10 min read
kmpiosinterop

TL;DR

  • A LoginResult sealed interface with four cases exported to Swift as four unrelated classes plus a base, so switch required a default: 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 when on 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.

A fifth case added to a sealed hierarchy breaking the Android build in four places immediately while the iOS build compiled and shipped, because a Swift switch over a class hierarchy requires a default branch that absorbs the new case silently

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.

What each construct can express at the Objective-C boundary, showing that a closed set of cases and a payload per case cannot be satisfied at once by anything Objective-C has, so the exporter emits an open class hierarchy

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.

A new sealed case producing two sequential build failures after the projection is in place — first in the converter whose when has no else, then in the Swift switch over the flat enum — where previously only the first occurred

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.


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".

Follow by RSS