Throwing across the bridge loses your exception type
Swift sees NSError with a wrapped message, so a typed catch becomes a string match.
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.

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.
Related
- Your Kotlin class becomes an Objective-C class, which is why the name is wrong — why the sealed type needs a projection
- Your suspend function becomes a callback in Swift, and cancellation does not survive — the other thing the bridge drops
- The iOS framework you export is an API, and nobody reviews it — designing what crosses deliberately
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".