Writing Swift-friendly Kotlin Multiplatform APIs — Part V: Exceptions

Learn how to code libraries that your iOS teammates will not frown upon using them. In this chapter: Exceptions!

André Oriani
ProAndroidDev

--

An android in an avalanche of thrown apples — DALL·E 2

The machine has just dispensed your expresso. You grab your cup, and, suddenly you notice a Macbook Pro with a Swift-logo sticker rushing in your direction. An angry face emerges from behind the laptop:
“It’s crashing!”. Your iOS teammate turns the screen at you, pointing to something that looks like assembly code. It takes a while for you to understand: “Ah! Why didn’t you catch the exception?”. “Why didn’t I catch the exception? Why didn’t I catch the exception? How dare you say that?!”. Fuming, the developer throws — pun intended — the Macbook at you. You are clueless about what just happened.

This article is part of a series, see the other articles here

In order to explain the reason behind the wrath of your Swift colleague, let’s remember something you did last summer: you wrote Java!

import java.io.IOException;

public class JavaReader {
int read() throws IOException {
throw new IOException("There's no data");
}

public static void main(String... args) {
final JavaReader reader = new JavaReader();
try {
int value = reader.read();
} catch (IOException ioe) {
System.err.println(ioe.getMessage());
}

}
}

If we rewrite this code again in pure Kotlin fashion it will be something like this:

import java.io.IOException

class KotlinReader {
fun read(): Int {
throw IOException()
}
}

fun main() {
// This compiles fines, although not advisable
val value = KotlinReader().run { read() }
}

Did you notice the difference? In Java IOException is a checked exception. That means that you must either handle it with a try-catch block or declare the exception in the function signature. Kotlin went away with checked exceptions. Let’s bring that to the multiplatform world :

// KOTLIN API CODE
import io.ktor.utils.io.errors.IOException

class Reader {
fun read(): Int {
throw IOException("No data to read")
}
}
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Reader")))
@interface SharedReader : SharedBase
- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer));
+ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead")));
- (int32_t)read __attribute__((swift_name("read()")));
@end
// HEADER "TRANSLATED" TO SWIFT 
public class Reader : KotlinBase {
public init()
open func read() -> Int32
}
// SWIFT CLIENT CODE
func readData() -> Int32 {
let reader = Reader()
do {
// Warning: No calls to throwing functions occur within 'try' expression
let value = try reader.read()
return value
} catch { // Warning: 'catch' block is unreachable because no errors are thrown in 'do' block
return -1
}
}

The Xcode’s warnings seem to not make sense at first. But as soon as readData() is invoked, the iOS app crashes, despite the do-try-catch block.

Function doesn't have or inherit @Throws annotation and thus exception isn't propagated from Kotlin to Objective-C/Swift as NSError.
It is considered unexpected and unhandled instead. Program will be terminated.
Uncaught Kotlin exception: io.ktor.utils.io.errors.IOException: No data to read
at 0 shared 0x10d9debf5 kfun:kotlin.Exception#<init>(kotlin.String?;kotlin.Throwable?){} + 133 (/opt/buildAgent/work/acafc8c59a79cc1/kotlin/kotlin-native/runtime/src/main/kotlin/kotlin/Exceptions.kt:25:63)
at 1 shared 0x10db71c57 kfun:io.ktor.utils.io.errors.IOException#<init>(kotlin.String;kotlin.Throwable?){} + 119 (/opt/buildAgent/work/8d547b974a7be21f/ktor-io/posix/src/io/ktor/utils/io/errors/IOException.kt:4:58)
at 2 shared 0x10db71cbd kfun:io.ktor.utils.io.errors.IOException#<init>(kotlin.String){} + 93 (/opt/buildAgent/work/8d547b974a7be21f/ktor-io/posix/src/io/ktor/utils/io/errors/IOException.kt:5:50)
at 3 shared 0x10d97d4c1 kfun:io.aoriani.kmpapp.Reader#read(){}kotlin.Int + 145
at 4 shared 0x10d981aa7 objc2kotlin_kfun:io.aoriani.kmpapp.Reader#read(){}kotlin.Int + 151
at 5 iosApp 0x10ceefa40 $s6iosApp8readDatas5Int32VyF + 64 (/Users/aoriani/Development/Multiplatform/KmpApp/iosApp/iosApp/ContentView.swift:25:32)
at 6 iosApp 0x10ceefd64 $s6iosApp11ContentViewVACycfC + 148 (/Users/aoriani/Development/Multiplatform/KmpApp/iosApp/iosApp/ContentView.swift:4:0)
at 7 iosApp 0x10ceef58c $s6iosApp6iOSAppV4bodyQrvgAA11ContentViewVyXEfU_ + 44 (/Users/aoriani/Development/Multiplatform/KmpApp/iosApp/iosApp/iOSApp.swift:7:4)
at 8 SwiftUI 0x113b75a01 get_witness_table 7SwiftUI4ViewRzlAA15ModifiedContentVyxAA25ComplicationIdiomModifierVGAaBHPxAaBHD1__AfA0cH0HPyHCHCTm + 60924
at 9 iosApp 0x10ceef45c $s6iosApp6iOSAppV4bodyQrvg + 156 (/Users/aoriani/Development/Multiplatform/KmpApp/iosApp/iosApp/iOSApp.swift:6:3)
at 10 iosApp 0x10ceef838 $s6iosApp6iOSAppV7SwiftUI0B0AadEP4body4BodyQzvgTW + 8
at 11 SwiftUI 0x113284dcf get_witness_table 7SwiftUI18DynamicViewContentRzlAA08ModifiedE0VyxAA21_TraitWritingModifierVyAA08OnDeleteG3KeyVGGAaBHPxAaBHD1__AiA0dI0HPyHCHCTm + 27673

Now do you understand why your colleague was mad at you? Even though there was an attempt to catch the Kotlin Exception, it cannot be captured in Swift with the current code.

Okay, let’s follow the recommendation that is in the crash log, and add a @Throws annotation in the same way we would do to support Java interoperability.

// KOTLIN API CODE
import io.ktor.utils.io.errors.IOException

class Reader {
@Throws(IOException::class)
fun read(): Int {
throw IOException("No data to read")
}
}
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Reader")))
@interface SharedReader : SharedBase
- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer));
+ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead")));

/**
* @note This method converts instances of IOException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
- (int32_t)readAndReturnError:(NSError * _Nullable * _Nullable)error __attribute__((swift_name("read()"))) __attribute__((swift_error(nonnull_error)));
@end
// HEADER "TRANSLATED" TO SWIFT
public class Reader : KotlinBase {
public init()
/**
* @note This method converts instances of IOException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
open func read() throws -> Int32
}

The new Objective-C signature for our read method may be confusing at first sight for people who are not used to pointers or have never programmed in C. Let’s write a pseudo-Kotlin implementation of it, with some poetic usage of Kotlin Native types:

// This is a pseudo-code!
fun readAndReturnError(error: CPointer<NSError?>?): Int {
try {
return read()
} catch(t: Throwable) {
// This is not like real CPointers works,
// but I am using like this for didatic reasons
error?.rawValue = t.asNSError()
}
}

The pretend CPointer above works as a container type, pretty much like Optional, but mutable. If some error happens when the function is called, an instance of NSError will be placed inside the error container. Then the caller can inspect the container. If it contains an NSError that means the function had failed. That is the convention for Cocoa frameworks — the set of Apple’s libraries like UIKit and Foundation, which are used to build iOS and MacOS apps. If some Cocoa method may fail, its last parameter will be a NSErrror** error. Objective-C does have exceptions but they are reserved “for programming or unexpected runtime errors such as out-of-bounds collection access, attempts to mutate immutable objects, sending an invalid message, and losing the connection to the window server”.

The good news is that when methods following the Cocoa convention are bridged to Swift, they are transformed into a method that throws the NSError, as we saw above. Kotlin takes advantage of that, exporting Objective-C methods that follow the Cocoa convention. That way, a Kotlin exception can be propagated and caught on the Swift side. Your iOS teammate can then use the extension kotlinExtension to get the Kotlin exception and downcast it to the expected exceptions:

func readData() -> Int32 {
let reader = Reader()
do {
let value = try reader.read()
return value
} catch let error as NSError {
print("NSError: \(error)")

switch error.kotlinException {
case let ioException as Ktor_ioIOException:
print ("Caught IOException")
print (ioException.message ?? "")
ioException.printStackTrace()
case let illegalStateException as KotlinIllegalStateException:
print ("Caught IllegalStateException")
print (illegalStateException.message ?? "")
default:
print ("Caught something")
}
return -1
}
}

The app no longer crashes, we can capture the exception and print a nice log:

NSError: Error Domain=KotlinException Code=0 "No data to read" UserInfo={NSLocalizedDescription=No data to read, KotlinException=io.ktor.utils.io.errors.IOException: No data to read, KotlinExceptionOrigin=}
Caught IOException
No data to read
io.ktor.utils.io.errors.IOException: No data to read
at 0 shared 0x1088de635 kfun:kotlin.Exception#<init>(kotlin.String?;kotlin.Throwable?){} + 133 (/opt/buildAgent/work/acafc8c59a79cc1/kotlin/kotlin-native/runtime/src/main/kotlin/kotlin/Exceptions.kt:25:63)
at 1 shared 0x108a716d7 kfun:io.ktor.utils.io.errors.IOException#<init>(kotlin.String;kotlin.Throwable?){} + 119 (/opt/buildAgent/work/8d547b974a7be21f/ktor-io/posix/src/io/ktor/utils/io/errors/IOException.kt:4:58)
at 2 shared 0x108a7173d kfun:io.ktor.utils.io.errors.IOException#<init>(kotlin.String){} + 93 (/opt/buildAgent/work/8d547b974a7be21f/ktor-io/posix/src/io/ktor/utils/io/errors/IOException.kt:5:50)
at 3 shared 0x10887b8c1 kfun:io.aoriani.kmpapp.Reader#read(){}kotlin.Int + 145
at 4 shared 0x1088804ab objc2kotlin_kfun:io.aoriani.kmpapp.Reader#read(){}kotlin.Int + 155
at 5 iosApp 0x107de192b $s6iosApp8readDatas5Int32VyF + 171 (/Users/aoriani/Development/Multiplatform/KmpApp/iosApp/iosApp/ContentView.swift:23:18)
at 6 iosApp 0x107de2834 $s6iosApp11ContentViewVACycfC + 148 (/Users/aoriani/Development/Multiplatform/KmpApp/iosApp/iosApp/ContentView.swift:4:0)
at 7 iosApp 0x107de140c $s6iosApp6iOSAppV4bodyQrvgAA11ContentViewVyXEfU_ + 44 (/Users/aoriani/Development/Multiplatform/KmpApp/iosApp/iosApp/iOSApp.swift:7:4)
at 8 SwiftUI 0x10db2ea01 get_witness_table 7SwiftUI4ViewRzlAA15ModifiedContentVyxAA25ComplicationIdiomModifierVGAaBHPxAaBHD1__AfA0cH0HPyHCHCTm + 60924
at 9 iosApp 0x107de12dc $s6iosApp6iOSAppV4bodyQrvg + 156 (/Users/aoriani/Development/Multiplatform/KmpApp/iosApp/iosApp/iOSApp.swift:6:3)
at 10 iosApp 0x107de16b8 $s6iosApp6iOSAppV7SwiftUI0B0AadEP4body4BodyQzvgTW + 8
at 11 SwiftUI 0x10d23ddcf get_witness_table 7SwiftUI18DynamicViewContentRzlAA08ModifiedE0VyxAA21_TraitWritingModifierVyAA08OnDeleteG3KeyVGGAaBHPxAaBHD1__AiA0dI0HPyHCHCTm + 27673
at 12 SwiftUI 0x10db0d8f2 __swift_memcpy49_8 + 14386
at 13 SwiftUI 0x10d23d498 get_witness_table 7SwiftUI18DynamicViewContentRzlAA08ModifiedE0VyxAA21_TraitWritingModifierVyAA08OnDeleteG3KeyVGGAaBHPxAaBHD1__AiA0dI0HPyHCHCTm + 25314
at 14 SwiftUI 0x10db0daaf __swift_memcpy49_8 + 14831
at 15 SwiftUI 0x10d193ff3 block_destroy_helper.6215 + 63948
at 16 AttributeGraph 0x7ff81fd7a1d6 _ZN2AG5Graph11UpdateStack6updateEv + 536
at 17 AttributeGraph 0x7ff81fd7a9aa _ZN2AG5Graph16update_attributeENS_4data3ptrINS_4NodeEEEj + 442
at 18 AttributeGraph 0x7ff81fd824f4 _ZN2AG5Graph20input_value_ref_slowENS_4data3ptrINS_4NodeEEENS_11AttributeIDEjPK15AGSwiftMetadataRhl + 394
at 19 AttributeGraph 0x7ff81fd999f0 AGGraphGetValue + 217
at 20 SwiftUI 0x10db0d9d8 __swift_memcpy49_8 + 14616
at 21 SwiftUI 0x10db0da9c __swift_memcpy49_8 + 14812
at 22 SwiftUI 0x10d193ff3 block_destroy_helper.6215 + 63948
at 23 AttributeGraph 0x7ff81fd7a1d6 _ZN2AG5Graph11UpdateStack6updateEv + 536
at 24 AttributeGraph 0x7ff81fd7a9aa _ZN2AG5Graph16update_attributeENS_4data3ptrINS_4NodeEEEj + 442
at 25 AttributeGraph 0x7ff81fd824f4 _ZN2AG5Graph20input_value_ref_slowENS_4data3ptrINS_4NodeEEENS_11AttributeIDEjPK15AGSwiftMetadataRhl + 394
at 26 AttributeGraph 0x7ff81fd999f0 AGGraphGetValue + 217
at 27 SwiftUI 0x10db2f887 get_witness_table 7SwiftUI4ViewRzlAA15ModifiedContentVyxAA25ComplicationIdiomModifierVGAaBHPxAaBHD1__AfA0cH0HPyHCHCTm + 64642
at 28 SwiftUI 0x10db2f93b get_witness_table 7SwiftUI4ViewRzlAA15ModifiedContentVyxAA25ComplicationIdiomModifierVGAaBHPxAaBHD1__AfA0cH0HPyHCHCTm + 64822
at 29 SwiftUI 0x10d133475 objectdestroy.35Tm + 23116
at 30 AttributeGraph 0x7ff81fd7a1d6 _ZN2AG5Graph11UpdateStack6updateEv + 536
at 31 AttributeGraph 0x7ff81fd7a9aa _ZN2AG5Graph16update_attributeENS_4data3ptrINS_4NodeEEEj + 442
at 32 AttributeGraph 0x7ff81fd81dbe _ZN2AG5Graph9value_refENS_11AttributeIDEPK15AGSwiftMetadataRh + 122
at 33 AttributeGraph 0x7ff81fd99a35 AGGraphGetValue + 286
at 34 SwiftUI 0x10d23c60a get_witness_table 7SwiftUI18DynamicViewContentRzlAA08ModifiedE0VyxAA21_TraitWritingModifierVyAA08OnDeleteG3KeyVGGAaBHPxAaBHD1__AiA0dI0HPyHCHCTm + 21588
at 35 SwiftUI 0x10e216492 block_destroy_helper.183 + 44405
at 36 SwiftUI 0x10e211b9e block_destroy_helper.183 + 25729
at 37 SwiftUI 0x10e212739 block_destroy_helper.183 + 28700
at 38 UIKitCore 0x108f7d798 +[UIScene _sceneForFBSScene:create:withSession:connectionOptions:] + 1393
at 39 UIKitCore 0x109d60465 -[UIApplication _connectUISceneFromFBSScene:transitionContext:] + 1317
at 40 UIKitCore 0x109d60a3d -[UIApplication workspace:didCreateScene:withTransitionContext:completion:] + 561
at 41 UIKitCore 0x10970636a -[UIApplicationSceneClientAgent scene:didInitializeWithEvent:completion:] + 349
at 42 FrontBoardServices 0x7ff80549db3a -[FBSScene _callOutQueue_agent_didCreateWithTransitionContext:completion:] + 414
at 43 FrontBoardServices 0x7ff8054cc7b9 __92-[FBSWorkspaceScenesClient createSceneWithIdentity:parameters:transitionContext:completion:]_block_invoke.187 + 101
at 44 FrontBoardServices 0x7ff8054abb89 -[FBSWorkspace _calloutQueue_executeCalloutFromSource:withBlock:] + 208
at 45 FrontBoardServices 0x7ff8054cc3ae __92-[FBSWorkspaceScenesClient createSceneWithIdentity:parameters:transitionContext:completion:]_block_invoke + 343
at 46 libdispatch.dylib 0x108334f5a _dispatch_client_callout + 7
at 47 libdispatch.dylib 0x1083388d1 _dispatch_block_invoke_direct + 495
at 48 FrontBoardServices 0x7ff8054f2bb7 __FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__ + 29
at 49 FrontBoardServices 0x7ff8054f2aad -[FBSSerialQueue _targetQueue_performNextIfPossible] + 173
at 50 FrontBoardServices 0x7ff8054f2bdf -[FBSSerialQueue _performNextFromRunLoopSource] + 18
at 51 CoreFoundation 0x7ff800387fe4 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 16
at 52 CoreFoundation 0x7ff800387f23 __CFRunLoopDoSource0 + 156
at 53 CoreFoundation 0x7ff800387780 __CFRunLoopDoSources0 + 307
at 54 CoreFoundation 0x7ff800381e22 __CFRunLoopRun + 926
at 55 CoreFoundation 0x7ff8003816a6 CFRunLoopRunSpecific + 559
at 56 GraphicsServices 0x7ff809cb1289 GSEventRunModal + 138
at 57 UIKitCore 0x109d5ead2 -[UIApplication _run] + 993
at 58 UIKitCore 0x109d639ee UIApplicationMain + 122
at 59 SwiftUI 0x10df4c666 __swift_memcpy93_8 + 11935
at 60 SwiftUI 0x10df4c513 __swift_memcpy93_8 + 11596
at 61 SwiftUI 0x10d5b07e8 __swift_memcpy195_8 + 12254
at 62 iosApp 0x107de164d $s6iosApp6iOSAppV5$mainyyFZ + 29 (/Users/aoriani/Development/Multiplatform/KmpApp/iosApp/iosApp/iOSApp.swift:<unknown>)
at 63 iosApp 0x107de16d8 main + 8
at 64 dyld 0x1080292be 0x0 + 4429353662
at 65 ??? 0x1159a652d 0x0 + 4657407277

Moral of the story: Always annotate your Kotlin APIs with @Throws listing all possible exceptions that could be thrown by your code, so your iOS teammate can catch them.

In this chapter, we learned we must declare exceptions in the Kotlin APIs so the Kotlin Native compiler will generate the code that will allow them to be caught in Swift. See you in the next chapter! Meanwhile, check the other episodes of this series:

Writing Swift-friendly Kotlin Multiplatform APIs

10 stories

References:

--

--

Brazilian living in the Silicon Valley, Tech Lead and Principal Mobile Software Engineer @WalmartLabs