Engineering

Throwing across the bridge loses your exception type

Swift sees NSError with a wrapped message, so a typed catch becomes a string match.

MobileByteSensei 9 min read
kmpiosarchitecture

TL;DR

Kotlin exceptions are unchecked, so nothing in a suspend fun signature says it can throw. Across the Obj-C bridge, a thrown Kotlin exception becomes an NSError whose userInfo carries the original as a KotlinException — and the type you were catching on is gone from Swift's point of view.

So iOS ends up matching on error.localizedDescription, which is a string, which changes when someone edits a message. And anything the compiler could have told you about exhaustiveness is gone.

Return the failure instead of throwing it. A sealed Result type in the signature survives the export, switches exhaustively in Swift, and makes the error part of the API rather than an accident of it.

The case this post is about

A login use case with three distinct, actionable failures:

// commonMain
class WrongCredentials : Exception("Email or password is incorrect")
class AccountLocked(val until: Instant) : Exception("Account locked")
class NetworkUnavailable : Exception("No connection")

class LoginUseCase(private val api: Api) {
    suspend fun login(email: String, password: String): Session {
        if (!network.isAvailable()) throw NetworkUnavailable()
        return when (val r = api.login(email, password)) {
            is Resp.Ok       -> r.session
            is Resp.Denied   -> throw WrongCredentials()
            is Resp.Locked   -> throw AccountLocked(r.until)
        }
    }
}

Android handles this cleanly:

try { useCase.login(email, pw) }
catch (e: WrongCredentials) { showFieldError() }
catch (e: AccountLocked)    { showLockedUntil(e.until) }   // typed access to `until`
catch (e: NetworkUnavailable) { showOfflineBanner() }

Three branches, three different UI treatments, one of which needs a field off the exception. Then iOS.

The symptom

Three findings.

Swift cannot catch the Kotlin type. The generated API throws NSError:

do {
    let session = try await useCase.login(email: email, password: pw)
} catch let error as NSError {
    // error.domain == "KotlinException"
    // What kind of failure was it?
}

catch let e as SharedWrongCredentials does not compile — exported Kotlin exceptions are not Swift Error types you can pattern-match.

So the iOS team matched on the message. The only thing available:

if error.localizedDescription.contains("incorrect") { showFieldError() }
else if error.localizedDescription.contains("locked") { showLocked() }
else { showGenericError() }

Which broke the day someone improved the copy from "Email or password is incorrect" to "Incorrect email or password" — the contains("incorrect") check still passed by luck, and a different message edit two weeks later did not.

And AccountLocked.until was unreachable. The field exists on the Kotlin object; getting at it from the NSError userInfo means casting back to the Kotlin type, which is possible and ugly:

if let kotlin = error.userInfo["KotlinException"] as? SharedAccountLocked {
    showLockedUntil(kotlin.until)        // works, and nobody discovers this
}

That cast is the tell. The data is there, the route to it is undiscoverable, and the fallback is a string match.

Why the obvious fix failed

"Make the exceptions conform to Swift's Error." You cannot — Kotlin exception classes export as Obj-C classes deriving from KotlinBase, and Swift's Error conformance is not something the export can add.

"Use @Throws so Swift gets a typed throw." @Throws(WrongCredentials::class) affects whether the export is throwing at all, not the Swift-visible type. Without it, a thrown exception from a non-@Throws function is undefined behaviour on the Obj-C side; with it, you get an NSError. Necessary, and it does not restore types.

"Put an error code on every exception." Workable and halfway:

class WrongCredentials : Exception("…") { val code = "WRONG_CREDENTIALS" }

Swift still has to dig the Kotlin object out of userInfo to read code, and the compiler still cannot tell anyone a fourth code exists.

"Catch everything in Kotlin and return a string message." Loses until, loses the distinction between actionable and not, and pushes presentation decisions into shared code.

The actual mechanism

Two language differences compounding.

Kotlin exceptions are unchecked and invisible in signatures. suspend fun login(...): Session says nothing about failure. There is no list of what it throws, so nothing can be exported.

Obj-C error handling is a convention, not a type system. The Obj-C idiom is an out-parameter NSError **, and Swift's importer turns that into throws. NSError carries a domain and a code — both strings/integers — and arbitrary userInfo. The Kotlin/Native runtime wraps the original exception into userInfo under KotlinException, which is why the data survives while the type does not.

And there is a sharper consequence than lost type information: only suspend functions and functions marked @Throws get the throws treatment at all. A plain Kotlin function that throws does not become a Swift throws function — the exception propagates into Obj-C as an unhandled one, and the app terminates.

fun parse(raw: String): Config = …        // throws IllegalArgumentException on bad input

From Swift that is a non-throwing call. There is no try, no compiler prompt, nothing to catch — the crash report shows KotlinIllegalArgumentException with a Kotlin stack trace and no indication that the caller could have handled it, because at the language boundary it could not.

So the type erasure and the crash risk are the same defect seen twice: Swift is being told less about failure than Kotlin knows. Which is why the fix below returns errors as values rather than improving how exceptions cross — a sealed result type carries both the type and the handleability across a boundary that transmits neither.

So the type information is not lost in transit; it is lost in expressibility. Obj-C has no way to say "this function throws one of these three types", so Swift cannot offer a typed catch, and exhaustiveness checking has nothing to check.

A Kotlin suspend function whose signature declares no failures, exported through an Obj-C convention that carries only a domain string, an integer code and arbitrary userInfo — so the original exception survives inside userInfo while the type disappears, leaving Swift to match on a localized description string that any copy edit can change

An exception is out-of-band control flow, and out-of-band channels are exactly what a foreign function interface narrows. Put the failure in the signature and it crosses like any other value.

The fix

Return a sealed result. The failure becomes data, and data exports well.

// commonMain
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
    data class Unexpected(val message: String) : LoginResult
}

class LoginUseCase(private val api: Api) {
    suspend fun login(email: String, password: String): LoginResult = try {
        if (!network.isAvailable()) return LoginResult.NetworkUnavailable
        when (val r = api.login(email, password)) {
            is Resp.Ok     -> LoginResult.Success(r.session)
            is Resp.Denied -> LoginResult.WrongCredentials
            is Resp.Locked -> LoginResult.AccountLocked(r.until)
        }
    } catch (e: CancellationException) {
        throw e                                       // cancellation is NOT a result
    } catch (e: Throwable) {
        LoginResult.Unexpected(e.message ?: "unknown")
    }
}

Four decisions:

Unexpected exists so the sealed type is total. Without it, an unforeseen throwable would escape as an NSError again and you would be back where you started for the rarest case — which is the one you understand least.

CancellationException is re-thrown, not captured as a result. A cancelled login is not a login failure, and swallowing it breaks structured concurrency. This is the single easiest mistake to make when converting exceptions to results.

The sealed type still needs a projection for exhaustive Swift switching — the same technique from the Obj-C export post, because a sealed hierarchy exports as an open class hierarchy:

// commonMain
enum class LoginOutcome { Success, WrongCredentials, AccountLocked, NetworkUnavailable, Unexpected }

class LoginResultView internal constructor(
    val outcome: LoginOutcome,
    val session: Session?,
    val lockedUntil: Instant?,
    val message: String?,
)

fun LoginResult.asView(): LoginResultView = when (this) {
    is LoginResult.Success          -> LoginResultView(Success, session, null, null)
    LoginResult.WrongCredentials    -> LoginResultView(WrongCredentials, null, null, null)
    is LoginResult.AccountLocked    -> LoginResultView(AccountLocked, null, until, null)
    LoginResult.NetworkUnavailable  -> LoginResultView(NetworkUnavailable, null, null, null)
    is LoginResult.Unexpected       -> LoginResultView(Unexpected, null, null, message)
}   // exhaustive — a new case breaks THIS, which is the alarm

And Swift gets a real switch with typed access to lockedUntil:

let result = await useCase.login(email: email, password: pw).asView()
switch result.outcome {
case .success:            enter(result.session!)
case .wrongCredentials:   showFieldError()
case .accountLocked:      showLocked(until: result.lockedUntil!)   // typed, discoverable
case .networkUnavailable: showOfflineBanner()
case .unexpected:         showGeneric(result.message)
}

No string matching, no userInfo archaeology, and adding a sixth outcome fails the Swift build.

Keep throwing for genuine programming errors — an IllegalArgumentException on a bad argument is not something a caller should branch on. The distinction worth drawing: expected failures are results; bugs are exceptions.

How we proved it

That a copy edit can no longer break iOS. The regression test for the original bug, which was untestable before because the behaviour lived in a Swift string match:

// commonTest
@Test
fun outcomeIsIndependentOfAnyUserFacingCopy() = runTest {
    api.next = Resp.Denied
    val view = useCase.login("[email protected]", "x").asView()
    assertEquals(LoginOutcome.WrongCredentials, view.outcome)
    // deliberately NOT asserting any message text
}

Pairing it with the old behaviour makes the point:

old:  message "Email or password is incorrect" → iOS matched contains("incorrect")
      message changed to "Incorrect email or password"
      → still matched, BY LUCK
      message changed to "Those details don't match our records"
      → fell through to showGenericError()      ← the shipped bug
new:  message changed to anything              → outcome unchanged, test green

That a new case breaks the Swift build. The property the string match never had:

// add LoginResult.RequiresTwoFactor
./gradlew :shared:compileKotlinIosArm64
  e: 'when' expression must be exhaustive — add 'is RequiresTwoFactor' branch   (asView)

// after adding it to the enum + mapper
xcodebuild
  error: switch must be exhaustive — add '.requiresTwoFactor'                   (LoginView.swift)

Both halves failing is what makes this safe to extend: Kotlin stops first, then Swift.

And lockedUntil reaches the UI. A device check, because the previous route was technically available and practically unused:

Resp.Locked(until = 2026-10-18T09:30Z)
  before:  generic error, no time shown  (userInfo cast never written)
  after:   "Locked until 09:30"

One limit: Unexpected is a catch-all, and anything landing in it has lost its type by construction. We log the throwable on the Kotlin side before converting, so the diagnosis is retrievable even though the Swift branch is generic — otherwise converting exceptions to results trades one kind of blindness for another.

What this generalizes to

Out-of-band control flow does not survive a boundary. Exceptions across an FFI, across a process, across HTTP, across a serialisation format. Each boundary narrows to values, so a failure you want the other side to handle should be a value before it gets there.

Putting failures in the signature is what lets a compiler help. The sealed result gave both languages exhaustiveness. Unchecked exceptions gave neither. This is the same argument as Result/Either types generally, and the FFI boundary is where it stops being a style preference.

A string is not a discriminator. Matching on a user-facing message couples your control flow to your copy, and copy is edited by people who are not thinking about control flow. Any time behaviour depends on a human-readable string, there is a missing enum.

Separate expected failures from bugs. Wrong credentials is an outcome; a null-pointer dereference is a defect. Modelling both as exceptions loses the distinction, and modelling both as results makes every caller handle impossible cases.

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