You own a KMP library consumed by an iOS app. What is your strategy for using @Throws across the public API to avoid crashes while keeping the Swift error model usable?
answer
- @Throws = public cross-language error contract
- List a base type; subclasses bridge automatically
- Wrap impl exceptions; never leak raw library types
- Recoverable = listed; programmer bugs = unlisted/fail-fast
- Changing the bridged set is an API-compat change
basics
~20 sDecide which failures are recoverable and list those in @Throws on the public functions Swift calls, so they become catchable errors. Leave only truly fatal bugs unlisted, and give Swift a small, predictable set of error types.
solid answer
~40 sTreat `@Throws` as the cross-language **error contract**. On the public surface that Swift consumes, design a small, stable hierarchy of **domain exceptions** (e.g. a sealed-ish base like `ApiException` with subclasses) and list the base type in `@Throws` so all recoverable variants bridge via subclass matching. Reserve unlisted (crash-on-boundary) behavior for genuine programmer errors you want to fail-fast on (e.g. `IllegalStateException` from contract violations). Avoid leaking platform/implementation exception types to Swift; **wrap** them at the boundary. For suspend APIs, ensure cancellation propagates. Provide Swift-friendly typed payloads (codes/enums on exception classes) so Swift can `as?`-downcast and branch. Document the bridged set, add tests that assert which exceptions are listed, and keep the set stable across versions because changing it is a binary/source-compat concern for iOS consumers.
code
kotlin · 9 linesopen class ApiException(val code: Int, message: String) : Exception(message)
class NotFoundException(message: String) : ApiException(404, message)
@Throws(ApiException::class) // base -> all subclasses bridge to Swift
suspend fun load(id: String): Dto = try {
http.get(id) // raw Ktor errors below
} catch (e: io.ktor.client.plugins.ClientRequestException) {
throw ApiException(e.response.status.value, e.message ?: "http error") // wrap, don't leak
}go deeper
Knows you should list recoverable exceptions so Swift can catch them.
Lists base types for subclass bridging and wraps implementation exceptions instead of leaking them.
Distinguishes recoverable vs fail-fast, gives typed payloads, and tests the annotation contract.
Owns @Throws as a versioned public contract, sets a stable hierarchy, enforces wrapping, and balances Swift ergonomics against fail-fast for bugs.
## Frame: @Throws is a public contract For an iOS-consumed KMP library, `@Throws` is not an implementation detail — it defines **which failures are catchable in Swift** and which **kill the app**. So treat it like any other API contract: small, intentional, documented, versioned. ## 1. Curate a domain-exception hierarchy Expose a **narrow, stable** set of exception types. A common pattern: one base per area plus subclasses. ```kotlin open class ApiException(val code: Int, message: String) : Exception(message) class NotFoundException(message: String) : ApiException(404, message) class RateLimitedException(val retryAfterSec: Int) : ApiException(429, "rate limited") @Throws(ApiException::class) // base listed -> all subclasses bridge fun load(id: String): Dto { ... } ``` Because `@Throws` matches by assignability, listing the **base** bridges every subclass while keeping the Swift `catch` surface coherent. ## 2. Wrap, don't leak Don't let arbitrary implementation exceptions (Ktor, SQL, serialization) reach Swift. **Catch and re-wrap** them into your domain types at the boundary so Swift sees a predictable model and you don't crash on an unlisted type. ## 3. Recoverable vs fatal - **Recoverable** (network, validation, not-found): list them → Swift can handle. - **Fatal/programmer error** (broken invariants): leave unlisted so the app **fails fast** at the boundary — sometimes the right choice for catching bugs in QA. Make this a deliberate decision, not an accident. ## 4. Swift ergonomics Give exceptions **typed payloads** (an `Int` code, an enum) so Swift can downcast `error.kotlinException as? RateLimitedException` and read `retryAfterSec`. Keep `message` meaningful for `localizedDescription`. ## 5. suspend & cancellation For `suspend` APIs, ensure `CancellationException` propagates (don't swallow), and put cleanup in `withContext(NonCancellable)`. ## 6. Stability & testing - Changing the `@Throws` set changes the generated ObjC/Swift signatures → a **compat concern** for iOS consumers; treat additions/removals as API changes. - Add tests asserting the public functions are annotated and that thrown impl exceptions get wrapped (not leaked). - Document the bridged set in the public API docs. ## Anti-patterns - Annotating with a broad `@Throws(Throwable::class)` — defeats fail-fast and hides bugs. - Letting raw library exceptions escape to Swift. - Inconsistent error models across functions, forcing Swift to special-case each call. ## Summary Design a **small, stable, wrapped** exception hierarchy; list bases on the Swift-facing surface; deliberately leave true bugs unlisted to fail fast; give typed payloads; and version the bridged set like public API.
- Why not just annotate everything with @Throws(Throwable::class)?It bridges everything, eliminating fail-fast on real bugs and giving Swift an undifferentiated catch-all; it also masks invariant violations you'd want surfaced in QA.
- Why is changing the @Throws set a compatibility concern?It changes the generated Objective-C/Swift signatures (presence of error: / throws), so iOS consumer code may stop compiling or behave differently; treat it as a public API change.
Like an HTTP API's documented status codes: expose a small, stable set Swift can program against, not your raw internal stack traces.
saying these in an interview costs you the question
- Using @Throws(Throwable::class) everywhere as a blanket
- Leaking raw Ktor/SQL/serialization exceptions to Swift
- Treating the bridged set as a free-to-change implementation detail
- No typed payloads, forcing Swift to parse message strings
- Ignoring cancellation/cleanup on suspend APIs