Kotlin-Swift Interopedia

repository·main·Indexed 20 days ago

https://github.com/kotlin-hands-on/kotlin-swift-interopedia

A learning resource and playground for exploring bidirectional interoperability between Kotlin/Native and Swift. It provides documentation and a functional iOS app to demonstrate how Kotlin language features—including data classes, enums, sealed classes, coroutines, and extensions—behave when called from Swift via the Objective-C bridge. Includes a feature support matrix and guidance on using community solutions like SKIE and KMP-NativeCoroutines to improve interop.

Tokens
30.5K
Snippets
97
Records
117
Agent score
66%

What's inside Kotlin-Swift Interopedia

  1. Use Kotlin Coroutines (Suspend functions and Flows) in Swift

    main

    Kotlin Coroutines are translated into Swift callbacks (and experimentally into async/await).

    • Suspend functions: Translated to callbacks or async/await.
    • Flows: Translated to callbacks or async/await. Note that generic type arguments are lost during this translation.

    Recommendation: To improve interop and provide proper cancellation support, use community libraries like SKIE or KMP-NativeCoroutines.

  2. Understand Kotlin Unit and Nothing types in Swift

    main

    When interoperating between Kotlin and Swift, the Kotlin types Unit and Nothing are mapped to KotlinUnit and KotlinNothing respectively.

    • KotlinUnit: Acts similarly to Unit in Kotlin or Void in other contexts. It is a concrete type that can be instantiated using KotlinUnit().
    • KotlinNothing: Represents the Nothing type. It is a type that cannot be instantiated; attempting to call an initializer for KotlinNothing will result in a compilation error. Functions returning KotlinNothing typically indicate that the function will never return normally (e.g., by throwing an exception).
    // KotlinUnit can be instantiated
    let unitInstance = KotlinUnit()
    
    // KotlinNothing cannot be instantiated
    // let nothingInstance = KotlinNothing() // Error: no init() function
  3. Interop of simple and custom types between Kotlin and Swift

    main

    The Kotlin/Swift Interop Playground demonstrates how both simple types (Strings, primitives like Int, Boolean, Char) and custom types (such as data class) can be passed as arguments and returned from function calls across the language boundary.

    When calling Kotlin functions from Swift, Kotlin top-level functions are typically accessed via a generated class named after the Kotlin file with a Kt suffix (e.g., TypesKt).

    // Kotlin side
    fun printInt(intType: Int): Int {
        println("Int type: $intType")
        return intType
    }
    
    fun printString(stringType: String): String {
        println("String type: $stringType")
        return stringType
    }
    
    data class CustomType(val name: String, val surname: String)
    
    fun printCustomType(customType: CustomType): CustomType {
        println("Custom type: $customType")
        return customType
    }
    // Swift side
    let intType = TypesKt.printInt(intType: 123)
    let stringType = TypesKt.printString(stringType: "abc")
    let customType = TypesKt.printCustomType(customType: CustomType(name: "Author name", surname: "Author surname"))
  4. How inline functions behave in Kotlin-Swift interop

    main

    When using Kotlin inline functions in a Swift environment, the functions are visible and callable via the generated header file (e.g., InlineFunctionKt.inlineFunction).

    Important Note: While the functions are callable, they behave as regular functions and are not actually inlined by the Swift compiler. The inline keyword in Kotlin serves to allow passing lambda expressions (like action: () -> Unit) as arguments, which Swift can then call using trailing closure syntax.

    // Kotlin definition
    inline fun inlineFunction(action: () -> Unit) {
        println("InlineFunction.inlineFunction() begin")
        action()
        println("InlineFunction.inlineFunction() end")
    }
    // Swift usage
    InlineFunctionKt.inlineFunction {
        print("I'm inside inline!!!")
    }
  5. Interop behavior for Kotlin Enum classes in Swift

    main

    By default, Kotlin/Native does not generate a native Swift enum for Kotlin enum class definitions. Instead, it generates a Swift class containing static elements that represent the enum entries.

    Because the generated type is a class and not a Swift enum, switch expressions in Swift are not exhaustive. You must always provide a default case when switching over a Kotlin enum instance to handle potential cases that the Swift compiler cannot verify.

    Key capabilities available in Swift:

    • Access enum elements via static properties (e.g., EnumClass.entryOne).
    • Access properties defined in the Kotlin enum constructor.
    • Access functions defined within the Kotlin companion object via the .companion property.
    • Call the values() function to retrieve all entries.
    // Kotlin definition
    enum class EnumClass(val type: String) {
        ENTRY_ONE("entry_one"),
        ENTRY_TWO("entry_two");
    
        companion object {
            fun findByType(type: String) = values().find { it.type == type }
        }
    }
    
    // Swift usage
    func useEnumClass() {
        let e1 = EnumClass.entryOne
        
        // Accessing properties
        let type = e1.type
        
        // Accessing companion object
        let found = EnumClass.companion.findByType(type: "entry_two")
        
        // Switching requires a default case
        switch e1 {
        case .entryOne: print("one")
        case .entryTwo: print("two")
        default: print("default")
        }
    }
  6. Understand Sealed Interface interoperability between Kotlin and Swift

    main

    When using Kotlin sealed interfaces in a Swift environment via the Interopedia bridge, the sealed hierarchy is not preserved as a single unified type in Swift. Instead, the bridge generates separate, unrelated protocols for each implementation of the interface.

    Key Behaviors:

    • Kotlin Side: The sealed interface works as expected. In when expressions, the compiler provides autocompletion for all possible subtypes, ensuring exhaustive checks.
    • Swift Side: The subtypes are generated as independent protocols that inherit from a base protocol but do not inherit from each other. Because they are seen as separate protocols, Swift's switch statements do not provide exhaustive pattern matching or autocompletion for all branches. You must use a default case in Swift switch expressions.
    • Workaround: To achieve Swift-like exhaustive pattern matching, you should use Swift enums. You can write custom bridge code to convert the Kotlin sealed interface into a Swift enum.
    // Kotlin: Exhaustive when expression
    sealed interface SealedInterfaces {
        interface First : SealedInterfaces {
            fun firstFunctionExample(): String
        }
        interface Second : SealedInterfaces {
            fun secondFunctionExample(): String
        }
    }
    
    fun sealedInterfaceExample(sie: SealedInterfaces) {
        when (sie) {
            is SealedInterfaces.First -> sie.firstFunctionExample()
            is SealedInterfaces.Second -> sie.secondFunctionExample()
        }
    }
    // Swift: Non-exhaustive switch expression (requires default)
    func switchOnSealedInterfaces(sealedInterfaces: SealedInterfaces) {
        switch(sealedInterfaces) {
        case is SealedInterfacesFirst: 
            print((sealedInterfaces as! any SealedInterfacesFirst as SealedInterfacesFirst).firstFunctionExample())
        case is SealedInterfacesSecond: 
            print((sealedInterfaces as! any SealedInterfacesSecond as SealedInterfacesSecond).secondFunctionExample())
        default: 
            print("default")
        }
    }
  7. Understand Kotlin suspend function interop in Swift

    main

    When calling Kotlin suspend functions from Swift, the behavior depends on the interop method used:

    1. Default Completion Handlers: By default, a Kotlin suspend function is translated into a Swift function that accepts a completion handler.
    2. Experimental Async/Await: With Swift 5.5+, there is an experimental way to map suspend functions directly to Swift's async/await syntax.

    Important Limitation: Neither the default completion handler nor the experimental async/await mapping provides native cancellation support. If a Swift Task is cancelled, the underlying Kotlin suspend function will continue to execute until it completes normally.

    // Default Completion Handler pattern
    ThingRepository().getThing(succeed: true, completionHandler: { thing, error in
        // do something
    })
    
    // Experimental Async/Await pattern
    Task {
        do {
            let thing = try await ThingRepository().getThingSimple(succeed: true)
            print("Thing is \(thing).")
        } catch {
            print("Found error: \(error)")
        }
    }
  8. How Kotlin value classes are represented in Swift

    main

    When a Kotlin function accepts a @JvmInline value class as a parameter, the value class is not visible as a distinct type in the generated Swift header (.h). Instead, the argument is unwrapped and expanded into its underlying primitive type.

    When calling such a function from Swift, you must pass the primitive value directly rather than attempting to instantiate the value class.

    // Kotlin side
    @JvmInline
    value class ValueClassExample(val t: Int)
    
    fun valueClassUsageExample(v: ValueClassExample): String {
        return "Value class usage example | ${v.t}"
    }
    // Swift side: The argument 'v' is treated as an Int32 (or the underlying primitive)
    FunctionWithValueClassParameterKt.valueClassUsageExample(v: 40)
  9. Limitations of Bounded Generics in Kotlin-Swift Interop

    main

    When using Kotlin's bounded generics (e.g., <T : BaseClass>) through the Kotlin-Swift interop layer, the generic type restriction is not supported in the generated Objective-C/Swift interface.

    While Kotlin enforces that a generic type must inherit from a specific class, the exported Objective-C interface lacks this metadata. Consequently, Swift code will compile even if you pass an object that violates the original Kotlin constraint (e.g., passing an NSString to a generic that was intended only for subclasses of a specific Kotlin class).

    // Kotlin side: Restriction is defined
    open class ForStricted
    class StrictedGeneric<T : ForStricted>(val data: T)
    
    // Swift side: Restriction is LOST
    // This will compile despite violating the Kotlin constraint
    let _ = StrictedGeneric(data: NSString("1122")) 
  10. Use Kotlin Extensions in Swift

    main

    The way you access Kotlin extensions in Swift depends on whether the target is a platform class or a standard class:

    • Usual Class (Extension function/property): Can be used directly on the class object.
    • Platform Class (Extension function/property): A wrapper class is generated. You must use the wrapper function which accepts an instance of the desired class as an argument.
    • Companion Object of Platform Class: Extension properties are present in the .h file but are impossible to use in Swift.
    • Companion Object of Usual Class: Extension properties can be accessed through the companion object.
  11. How Kotlin functions expecting a lambda with receiver map to Swift

    main

    In Kotlin, you can define functions that accept a lambda with a receiver (e.g., UsualClassExample.() -> Unit). This allows the lambda body to access the receiver's members directly using this or implicit access.

    When calling these functions from Swift via interop, the Kotlin extension function is transformed into a standard Swift closure where the first parameter is the receiver object. You must explicitly name this parameter (e.g., usualClassExample in) and use it to access the object's properties.

    // Kotlin: Lambda with receiver allows implicit 'this'
    fun funcWithExtension(extension: UsualClassExample.() -> Unit) {
        val someObject = UsualClassExample(/*...*/) 
        someObject.extension()
    }
    
    // Usage in Kotlin
    funcWithExtension {
        this.param1 = "changed"
        println(param1)
    }
    // Swift: The receiver is passed as the first argument to the closure
    FunctionWithExtensionKt.funcWithExtension(extension: { usualClassExample in
        usualClassExample.param1 = "changed"
        print("\(usualClassExample.param1)")
    })
  12. Remove nullability from Kotlin generics for Swift interop

    main

    By default, Kotlin generics are seen as Any? in Swift. To ensure the Swift side sees the return type as Any (non-nullable) instead of Any?, use the boundary syntax <T : Any> in your Kotlin function definition. This forces the type to be non-nullable.

    // Kotlin side
    fun <T : Any> strictedGeneric(data: T): T {
        return data
    }
    
    // Swift side will see 'Any' instead of 'Any?'