Hello there :slightly_smiling_face: We at Kotlin/N...
# swift-export
a
Hello there šŸ™‚ We at Kotlin/Native would like to gather feedback from our users on some defaults of the export. We are curious on how good the current default in Objective-C export is, and should we choose to continue aligning Swift Export with it, or should we change it drastically. And the language feature we want to talk to you today is 🄁🄁🄁🄁 Error Handling. Error stories differ drastically between our target languages. On the Kotlin side - Exceptions are unchecked. That means that every function can throw. The current implementation of Swift Export utilizes untyped throws to represent failures. In this model, the Swift signature explicitly reflects the throwing nature of a function, which in turn obliges the call site to use try to manage or forward the resulting error. Sadly, that difference between languages makes it not clear on what solution would be preferable. Should we maintain the status quo from Objective-C export by keeping functions non-throwing by default? Or is it time to pivot toward language correctness, marking exported callables as throwing to better align with how Kotlin actually behaves? Please come to the thread for a detailed explanation of the problem šŸ™‚
The Objective-C export currently utilizes an opt-in model for handling error propagation. In this setup, exported Kotlin functions are non-throwing by default in Swift. To change this, a Kotlin author must manually apply the @Throws annotation, which forces the generated Swift signature to include throws and requires call sites to use try. There are some obvious advantages to this approach: 1. Swift call sites stay clean and uncomplicated. 2. The library author has full control over which APIs expose failures. 3. Since @Throws alters the Swift signature, the compiler helps identify places that need updating. However, we also face significant hurdles: 1. Kotlin does not validate that the annotation covers all possible throwables. 2. It is quite easy for developers to miss an annotation or let the exception list get stale. 3. Any unmapped exception crossing the boundary will result in a runtime crash. 4. Currently, there is no way to express throwing behavior for closures or function types in this manner. Ultimately, this model prioritizes fewer try keywords in Swift but places the burden of accuracy entirely on the Kotlin developer. Enter structured concurrency In Swift Export, Kotlin suspend functions are mapped directly to Swift async functions. Suspend functions throw CancellationException to signal cancellation, which have to arrive on the swift side. This creates a discrepancy where very similar Kotlin functions look different in Swift:
Copy code
// kt
fun load(): Int
suspend fun load(): Int
Copy code
// swift 
func load() -> Int
func load() async throws -> Int
As you can see, adding suspend currently impacts two distinct traits at once: 1. The function turns asynchronous. 2. The function becomes throwing by default. Ideally, these would be separate concerns. The suspend modifier should imply async, but it shouldn't automatically determine the throwing status.
So we want to know: which of these trade-offs feels like the most idiomatic Swift Export? Let’s consider the following code:
Copy code
fun value(): Int = 5
fun failure(): Int = throw Throwable("hello there")

suspend fun suspendValue(): Int = 5
suspend fun waitForever(): Int = awaitCancellation()
kodee intensifies 3
//------------------------ //
1/ Which side should require an Annotation Since Kotlin functions are unchecked and lack explicit throw declarations, Swift Export is forced to establish a baseline behavior and offer a manual override mechanism. If used accurately, either approach is capable of generating an identical Swift interface. Option A - @Throws Basically this will preserve the current status quo of the Objective-C export model. This will leave us with the following changes to original code snippet
Copy code
fun value(): Int = 5

@Throws(Throwable::class)
fun failure(): Int = throw Throwable("hello there")
And the following call site
Copy code
value() // returns 5
try failure() // throws
In the standard configuration, Kotlin functions are translated as non-throwing. Developers must manually apply the @Throws annotation to specify which APIs should be exported as throwing to the Swift side. The advantage is a cleaner Swift call site by default. The disadvantage is that forgetting @Throws can turn an escaping Kotlin exception into runtime termination. Option B - @NonThrowing This will leave us with the following changes to original code snippet
Copy code
@NonThrowing
fun value(): Int = 5

fun failure(): Int = throw Throwable("hello there")
And the following callsite
Copy code
value() // returns 5
try failure() // throws
Unannotated Kotlin functions are exported as throwing. Authors use @NonThrowing for APIs that promise not to expose failures to Swift. The advantage is that forgetting an annotation only produces an unnecessary try, rather than an uncatchable failure. The disadvantage is that Swift APIs become throwing unless the Kotlin author explicitly opts out
1ļøāƒ£ 9
2ļøāƒ£ 5
šŸ…±ļø 1
//------------------------ //
2/ Should cancellation always make a suspend function throwing? Option A: Cancellation always uses the error channel
Copy code
try await value() // never throws
try await waitForever() // receives CancellationError when cancelled
Every exported suspend function is at least async throws, even when it does not expose any ordinary Kotlin exceptions. This ensures that cancellation can always cross the interop boundary safely Option B: Allow an explicitly non-throwing async export
Copy code
await value()
await waitForever() // runtime termination if cancellation crosses the boundary
The Kotlin author can explicitly request an async function without throws. This removes try from the Swift call site, but cancellation—or any other escaping throwable—cannot be delivered as a Swift error and therefore terminates at runtime. Which behavior do you prefer? 1. Always preserve cancellation: every exported suspend function is async throws. 2. Allow an explicit opt-out: an annotation can export a suspend function as async without throws, accepting runtime termination if cancellation or another throwable escapes.
2ļøāƒ£ 1
1ļøāƒ£ 12
//------------------------ //
3/ What should @Throws say about exception types? Regardless of which default we choose, we also need to decide what Swift Export should do with the list of exception classes stated in @Throws. Option A: Require and enforce the list
Copy code
@Throws(NetworkException::class) fun load(): Data = ...
@Throws(ParseException::class) fun parse(): Model = ...
Only listed exceptions and their subclasses are propagated to Swift. An unlisted exception escaping the function terminates at runtime. This makes the annotation an explicit failure contract, but Kotlin cannot verify that the list is exhaustive or remains up to date(mirrors current Objective-C export behaviour). Option B: Make @SwiftThrows as a marker, same as @Throws(Exception::class)
Copy code
@Throws(Exception::class) fun load(): Data = ...
@SwiftThrows fun parse(): Model = ...
The annotation only controls whether the Swift function is generated with throws. Any uncaught Kotlin Throwable escaping the call is propagated as a Swift Error. This avoids stale exception lists, but provides no documentation about the expected failures. Option C: Keep an optional, documentation-only list
Copy code
// theoretical new syntax, possibly a new annotation
@Throws(NetworkException::class) fun load(): Data = ...
@Throws fun parse(): Model = ...
The listed exceptions describe expected failures, but do not act as a runtime whitelist. Any escaping Kotlin Throwable is still propagated to Swift. What should @Throws mean? 1. A required and enforced list of exceptions. 2. A marker without an exception list; propagate every escaping throwable. 3. An optional documentation-only list; propagate every escaping throwable regardless of the list.
1ļøāƒ£ 2
2ļøāƒ£ 1
3ļøāƒ£ 12
g
1/2 — Option A for both. For error annotation, I’d rather keep the clean default (non-throwing) and put the burden on the Kotlin author via @Throws — most Kotlin runtime exceptions (NPE, IllegalState, etc.) aren’t really part of the intended contract, so defaulting everything to throwing would make Swift call sites unbearably noisy. For cancellation, always async throws — cancellation timing is outside the Kotlin author’s control, so authors shouldn’t be able to opt out of it and risk a crash over it. This is reinforced by how the current model already behaves: today, if you put a custom @Throws list on a suspend fun, you have to remember to explicitly re-add CancellationException yourself, e.g.:
Copy code
@Throws(RateLimitException::class, CancellationException::class)
suspend fun getSet(setCode: String): Result<List<Card>>
That’s exactly the kind of ā€œeasy to forgetā€ annotation staleness you called out elsewhere — if someone lists their business exceptions but forgets CancellationException, cancellation stops being able to cross the boundary and you get a runtime crash for a concern that has nothing to do with the business logic the author was thinking about. Making cancellation propagation automatic and independent of @Throws removes that footgun entirely. 3 — leaning toward Option C. An enforced whitelist (A) just moves the ā€œforgot to updateā€ crash risk one level deeper: even a function the author did remember to annotate can still crash if a new exception type shows up from a transitive dependency. Since Kotlin can’t verify exhaustiveness anyway, I’d rather the list be documentation-only and never gate what actually crosses the boundary — same runtime behavior as the bare marker (B), but without throwing away useful info for Swift consumers/tooling. One question this raises: with 1+2 combined, a suspend fun without @Throws is already async throws because of cancellation. In that case, does an unannotated business exception also get to ride that already-open throws channel, or does it still crash even though the signature says throws? If it still crashes, that feels like a sharper footgun than the non-suspend case, since the Swift dev sees throws and reasonably assumes any error is catchable.
f
Is there some downside to question 2? Seems like B just gives us option to do something more when we opt in without any downside.
a
> Is there some downside to question 2? We internally are not very fond of the idea, that adding
suspended
to a function forces to add two additional effect handlers on the callsite - try block and async context. So we are wondering, what's best - addition of suspension requires only a single change to the callsite, but adds a footgun, or force user to catch an exception that probably is never thrown. On the intersection of this question with it's neighbours there are options for introducing new annotations 1.
@Cancellable
(adds throws, basically equal to
@Throws(CancellationException::class)
) 2.
@nonCancellable
(removes throws, crashes runtime in case of cancellation)
Namings used here are theoretical, not a final design šŸ™‚
One question this raises: with 1+2 combined, a suspend fun without @Throws is already async throws because of cancellation. In that case, does an unannotated business exception also get to ride that already-open throws channel, or does it still crash even though the signature says throws?
That's exactly the problem, yes, If
suspend
fun implicitly adds
Throws(Cancellation)
to it's signature - should explicit
Throws
override it? Or cancellation should always be added to the list? Or maybe a
nonCancellable
marker would make more sence?
> I’d rather the list be documentation-only and never gate what actually crosses the boundary — same runtime behavior as the bare marker (B), but without throwing away useful info for Swift consumers/tooling. What do you think about a possibility of calculating the last common ancestor of the listed exceptions and marking
throws
on the swift side with it? This way a following code would make sence (not exact syntax, but the idea)
Copy code
sealed interface MyErrors: Throwable("")

object ParseError: MyErrors
object AuthError: MyErrors
object ServerError: MyErrors

@Throws(ParseError::class, AuthError::class)
fun doWork() = TODO()

// swift
func doWork() throws(MyErrors) { /**/ }

do { doWork() }
catch let e as MyErrors {
  switch e.sealed {
  case .parse: /**/
  case .auth: /**/
  case .server: /**/
  }
}
But with this throwing a IlligalStateException would crash the runtime
g
> That’s exactly the problem, yes, If
suspend
fun implicitly adds
Throws(Cancellation)
to it’s signature - should explicit
Throws
override it? Or cancellation should always be added to the list? Or maybe a
nonCancellable
marker would make more sence? For me, cancellation should always get merged into the list, not overridable by an explicit @Throws. If we let @Throws replace it instead of merging, we’re basically back to square one — someone writes @Throws(BusinessError::class), forgets cancellation exists, and boom, same crash risk we just decided to remove in Q2. The whole point was to take this off people’s mental checklist, so an override that quietly undoes that feels like a trap. šŸ˜… @NonCancellable I’m a bit wary of, not against it. It’s Q2's Option B in disguise — an opt-out that trusts the author’s function really can’t be cancelled. But the compiler can’t check that, same problem as the @Throws list in Q3: if the author’s wrong, or a suspension point sneaks in later, it’s a silent crash with no warning.
āœļø 1
What do you think about a possibility of calculating the last common ancestor of the listed exceptions and marking
throws
on the swift side with it? This way a following code would make sence (not exact syntax, but the idea)
Love the ideia šŸ™‚ and what if, instead of crashing on the mismatch, the generated Swift error type had a synthesized catch-all case e.g:
.other(Error)
For anything outside the declared hierarchy? You’d keep the nice switch for modeled cases, but unexpected stuff falls into .other instead of terminating. Best of both: good UX when errors are well-modeled, no crash risk when they’re not. This only pays off if your exceptions already share one sealed hierarchy. If not, the LCA collapses toward Throwable and you lose the switch granularity anyway — so I’d treat it as an opt-in enhancement for well-modeled APIs, not a replacement for the ā€œnever crash on unlisted exceptionsā€ default.
a
If not, the LCA collapses toward Throwable
Yeah, that's our concern also - that such an approach would collapse into
Throwable
in 90% cases and that's not very useful
kodee sad 1
šŸ‘ 1
g
Perhaps keeping Option C as the default and treat LCA/typed-throws as an opt-in nicety for the APIs where it actually pays off.
f
can we translate a kotlin typed throws into swift typed throws ?
a
can we translate a kotlin typed exception with into swift typed throws ?
Only if we calculate LCA - as Swift allows only one type to be listed šŸ™‚ But there are nuances: 1. for example - we expect that most cases would be calculated into Exception::class 2. Throwing something that does not inherit the resulted LCA would force us to crash the runtime 3. Such LCA on suspend functions would always be either Exception or CancellationException
f
It's still the Swift-style way to handle errors properly from swift. Translating a kotlin exception into a common Swift Error enum could be possible?
a
still the Swift-style way to handle errors properly from swift
Let me cite an original proposal of those šŸ™‚
even with the addition of typed throws to Swift, untyped
throws
is better for most scenarios. Consider typed throws only in the following circumstances:
1. In code that stays within a module or package where you always want to handle the error, so it's purely an implementation detail and it is plausible to handle the error.
2. In generic code that never produces its own errors, but only passes through errors that come from user components. The standard library contains a number of constructs like this, whether they are
rethrows
functions like
map
or are capturing a
Failure
type like in
Task
or
Result
.
3. In dependency-free code that is meant to be used in a constrained environment (e.g., Embedded Swift) or cannot allocate memory, and will only ever produce its own errors.
None of those are a good match for our case.
šŸ‘ 2
w
At least if suspend fun becomes a throws function in Swift, then this should default to throwing Throwable, not only CancellationException. Otherwise you get runtime crashes even though Swift has to handle exceptions already anyway. That's confusing and harder to debug (Swift developers can't obviously see that there's a forgotten "throws"). Also, at least for suspend functions I think it's the best default to have them marked as throws also because those functions most often do network requests or other error-prone operations. For non-suspend functions neither default is perfect, but probably non-throws by default is ok from our experience.
kodee greetings 1
āœļø 1
e
I’m late to the party and this is probably a bad idea for reasons I haven’t considered, but would it be possible to support typed Swift
throws
by having the Kotlin function return some version of a
Result
type? In other words, Kotlin returns
Result<SucessType, ErrorType>
and that translates to
func myFunc() throws(ErrorType) -> SuccessType
in Swift? This doesn’t solve the issue with
suspend
functions but I think it would solve some of the problems with the
@Throws
annotation that @Artem Olkov mentioned at the top of this thread: you can’t return a failure type that you didn’t explicitly declare and you are forced to make all thrown errors conform to a superclass.