· 21 min read Posted by Daniel Bertoldi

The Future of KMP's iOS Interop

Kotlin's new Swift Export feature promises to eliminate the Objective-C middleman and generate native Swift APIs directly from your KMP code. But with SKIE already solving many of the same pain points in production today, which approach should your team bet on? We break down the capabilities, limitations, and tradeoffs of both.
Ian Talmacs https://unsplash.com/photos/a-black-and-a-white-chess-piece-on-a-checkered-board-wr4CnlEg_5s
Credit: Ian Talmacs https://unsplash.com/photos/a-black-and-a-white-chess-piece-on-a-checkered-board-wr4CnlEg_5s
Pro

This article appeared first in Touchlab Pro Essentials. Get premium KMP resources and support with Touchlab Pro →

Before the release of SKIE in 2023, Kotlin Multiplatform (KMP) developers targeting iOS have shared a common bottleneck: the Objective-C bridge. Sealed classes degrade into standard classes, flows become clunky callback interfaces, and suspend functions lose their native concurrency. Touchlab answered this challenge with SKIE (Swift Kotlin Interface Enhancer), a compiler plugin that enhances the Obj-C bridge to generate idiomatic Swift code.

In 2024, with the release of Kotlin 2.0.20, JetBrains introduced an architectural shift. Swift Export promised to eliminate the Obj-C middleman entirely by generating native Swift APIs directly from your Kotlin code. As of this post’s writing, Swift Export just hit alpha, with improved concurrency support.

Since Swift Export and Objective-C Export are mutually exclusive, the question becomes: Should your team adopt the experimental Swift Export, or use the battle-tested Objective-C along with SKIE’s enhancements?

To answer that we won’t just compare features, or just point out that SKIE is not experimental and is currently used for scaled production apps: we’ll look at the compiled output of both configurations to see exactly how they handle each scenario and let the code speak for itself.

Understanding the Objective-C Illusion

Before we compare features, we need to understand how Kotlin actually talks to iOS.

When you compile a standard KMP project for iOS, the Kotlin compiler generates an Objective-C framework. It creates a compiled binary and a C-style header file (e.g, Shared.h). It does not generate Swift code.

SKIE is a compiler plugin that works alongside this Objective-C bridge. For basic scenarios, SKIE does nothing: it just lets the standard Objective-C bridge do its job, but for complex types (like Flows and Sealed Classes), it intercepts the build process to manipulate the Objective-C headers and generate supplementary Swift code.

Architectural Foundation

Let’s start with something really simple, just to get a feel of how each configuration lays the foundation for every other feature later on.

Suppose you have this code in your commonMain source set:

package com.example

class UsersService {
    fun getUsers(): List<String> = listOf("Carla", "Bob", "Charlie")
}

This is the generated file by both configurations:

Pure Objective-C export

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("UsersService")))
@interface SharedUsersService : SharedBase
- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((Obj-C_designated_initializer));
- (NSArray<NSString *> *)getUsers __attribute__((swift_name("getUsers()")));
@end

Since this is a really simple class, SKIE leaves it alone. This is the generated interface based on the header file:

public class UsersService : KotlinBase {

    public init()

    open func getUsers() -> [String]
}

Swift Export

extension com.example {

    final public class UsersService : KotlinBase {

        public init()

        public func getUsers() -> [String]
    }
}

Note: for brevity, I will omit the generated Objective-C header files from now on unless they contain something fascinating to talk about

Right out of the bat you’ll notice some pretty interesting differences between both mechanisms:

  1. Inheritance & Subclassing: Notice that the Obj-C bridge generates a public class with open func methods. For final Kotlin classes, it uses a hidden attribute to block subclassing, but if you mark a class as open in Kotlin, the Obj-C bridge allows you to subclass it in Swift. Swift Export, however, currently marks everything as final. This isn’t a design choice—it’s a hard limitation. Swift Export does not yet support cross-language inheritance, so even your open Kotlin classes will be strictly final on the Swift side.
  2. Underlying Types: Both configurations present [String] to the Swift developer, but their foundations differ. Obj-C Export uses NSArray, which benefits from Apple’s “toll-free bridging”, a mechanism that makes passing lists between languages nearly free. Swift Export generates a native Swift collection signature and we’d need a deep dive into the runtime to see if it’s a proxy or an eager copy, but the move toward native Swift types is a clear signal of JetBrains’ goal: making KMP projects entirely Objective-C independent.
  3. Namespaces and Packaging: In the Obj-C bridge, your classes all live in a single flat namespace. To avoid collisions in the header, KMP prepends a prefix (like SharedUsersService), but this is stripped when imported into Swift, so it appears as a clean UsersService. Swift Export, however, preserves your full Kotlin package structure. It maps packages to nested Swift enums, resulting in com.example.UsersService.
    • Note: Because these packages are Swift enums, you can’t simply import com.example to drop the prefix in your iOS code. It’s more verbose, but strictly faithful to your Kotlin architecture. If you prefer the flattened approach, Swift Export offers a flattenPackage Gradle property to strip the namespaces.

You can read more about Swift Export package mapping here.

The Takeaway

Notice that while Objective-C Export doesn’t produce Swift code, the mapping Apple built into Swift compiler makes it work on par with Swift Export’s Swift API.

The Native Advantage (Where Swift Export Wins)

Nullable Primitive Data Types

Now, let’s add a function to our UserService class that can return a nullable Int:

package com.example

class UsersService {
    fun getPrimaryUserId(): Int? = 1
}

And once again compare how both mechanisms handle this:

Pure Objective-C export

open class KotlinInt : KotlinNumber {

    public init(value: Int32)

    public convenience init(int value: Int32)
}

public class UsersService : KotlinBase {

    public init()

    open func getPrimaryUserId() -> KotlinInt?
}

Swift Export

extension com.example {

    final public class UsersService : KotlinBase {

        public init()

        public func getPrimaryUserId() -> Int32?
    }
}

Swift Export can convert nullable primitive types directly (although to Int32, not Swift’s Int) since it’s not tied to Obj-C limitations.

Obj-C export has to create wrapper classes like KotlinInt to preserve nullability because Obj-C primitives can’t be nil, so it needs a wrapper class. Because of this, you can only extract the value by using the .int32Value property from the wrapper class.

Typealias

Let’s update the example UsersService class from before and add a typealias:

package com.example

typealias UserList = List<String>

class UsersService {
    fun getUsers(): UserList = listOf("Carla", "Bob", "Charlie")
}

And, once again, compare their Swift output:

Pure Objective-C export

public class UsersService : KotlinBase {

    public init()

    open func getUsers() -> [String]
}

Swift Export

extension com.example {

    public typealias UserList = [String]

    final public class UsersService : KotlinBase {

        public init()

        public func getUsers() -> ExportedKotlinPackages.com.example.UserList
    }
}

Obj-C export does not preserve typealias. The underlying type is still NSArray<NSString *>.

Multi Module Support

Let’s suppose you now have an Analytics module with this class:

// :analytics module
package com.example.analytics

class AnalyticsService {
    fun trackEvent(name: String) = "Tracked: $name"
}

Pure Objective-C export

// in Shared module
public class UsersService : KotlinBase {

    public init()

    open func getPrimaryUserId() -> KotlinInt?

    // all other methods
}

public class AnalyticsService : KotlinBase {

    public init()

    open func trackEvent(name: String) -> String
}

Swift Export

// in Analytics module
extension com.example.analytics {

    final public class AnalyticsService : KotlinBase {

        public init()

        public func trackEvent(name: String) -> String
    }
}

The Obj-C Approach: The Flat Umbrella

Because Objective-C lacks a native mechanism for hierarchical organization (relying instead on a globally flat namespace), the standard KMP bridge has historically forced developers to bundle every Kotlin module into a single, massive “umbrella” framework—often just called Shared.framework.

While it is technically possible to split this into multiple frameworks that depend on a common core, doing so is a high-friction, manual task in Xcode. Without a carefully managed shared-binary architecture, simply embedding multiple independent Kotlin frameworks leads to severe ambiguity: each framework would instantiate its own Kotlin runtime and duplicate every base class.

This creates two distinct problems:

  1. Zero Encapsulation: Everything in your :analytics module becomes visible through Shared.framework, regardless of whether the consumer actually needs it. There’s no way to say, “export this module to iOS, but only expose these specific types.”
  2. Monolithic Linkage: Even with incremental compilation, KMP’s final Obj-C framework linking phase is a heavy, all-or-nothing process. Changes to a submodule trigger a relink of the entire framework.

The Swift Export Approach: True Swift Modules

Swift Export does not fix the underlying KMP runtime limitation. Your Kotlin code still compiles to a single shared binary under the hood to prevent memory issues. However, it fixes the surface area by generating truly independent Swift modules (often consumed as separate targets in a Swift Package).

This fundamentally fixes KMP’s modularity problem for the iOS developer:

  1. Honest Dependency Graphs: The separation you defined in Kotlin is preserved as Swift modules. A SwiftUI view dealing with analytics simply declares import Analytics. It doesn’t need to know that Shared even exists.
  2. Isolated Swift Recompilation: To be clear, this unfortunately won’t speed up your end-to-end build pipeline since the Kotlin compiler must still perform its monolithic build and link phase to generate the main object binary whenever Kotlin code changes. However, on the Xcode side, separate Swift modules prevent a change in one Kotlin module from triggering a massive recompilation cascade across your entire Swift codebase.

The Takeaway:

Obj-C export flattens your entire project into a single Objective-C framework where all types share the same namespace. Swift Export still shares a single Kotlin runtime under the hood, but it wraps it in native, isolated Swift modules, giving iOS developers honest dependency graphs and cleaner imports.

This means that in the code you can call AnalyticsService like this:

Pure Objective-C export

import Shared

struct ContentView: View {
    private let analytics = AnalyticsService()

    var body: some View {
        HomeScreenView()
    }.onAppear {
        analytics.trackEvent(name: "event")
    }
}

Swift Export

import Shared
import Analytics // <--- AnalyticsService is in a separate module

struct ContentView: View {
    private let analytics = com.example.analytics.AnalyticsService()

    var body: some View {
        HomeScreenView()
    }.onAppear {
        analytics.trackEvent(name: "event")
    }
}

The Middle Ground (Ergonomics vs Semantics)

Enums

Let’s add an enum to our UserService class:

package com.example

class UsersService {
    fun getUserStatus(): UserStatus = UserStatus.PENDING
}

enum class UserStatus {
    ACTIVE,
    inactive,
    another_Status,
    PENDING;
}

SKIE

// runtime support types for enum classes

@frozen public enum UserStatus : Hashable, CaseIterable {

    case active

    case inactive

    case anotherStatus

    case pending

    // other convenience accessors and bridging methods
}

extension UserStatus {

    public func toKotlinEnum() -> __UserStatus
}

extension __UserStatus {

    public func toSwiftEnum() -> Shared.UserStatus
}

Swift Export

public enum UserStatus : KotlinRuntimeSupport._KotlinBridgeable, CaseIterable, LosslessStringConvertible, RawRepresentable {

    case ACTIVE

    case inactive

    case another_Status

    case PENDING

    // other convenience accessors and bridging methods
}

final public class UsersService : KotlinBase {

    public init()

    public func getUserStatus() -> ExportedKotlinPackages.com.example.UserStatus
}

Note: For simplicity sake, I cut out various helper methods and developer comments out. As we start to test more complex features we will have to do this more and more.

Both configurations handle enums well, but with distinct philosophies:

  • Naming: SKIE converts case names to idiomatic Swift camelCase (anotherStatus), while Swift Export preserves Kotlin naming verbatim (another_Status).
  • Exhaustivity: SKIE generates a @frozen enum, enabling exhaustive switch statements without the annoying @unknown default case. Swift Export skips @frozen but conforms to LosslessStringConvertible and RawRepresentable, giving you native serialization helpers like UserStatus(rawValue: 2) for free.
  • The “Generic” Bridge: A key feature of SKIE is its toKotlinEnum() and toSwiftEnum() conversion helpers. These aren’t just for convenience; they are essential when working with generic Kotlin classes. Since Swift’s generic system often requires the underlying Objective-C class version of an enum (the “ugly” version), these helpers allow you to bridge your nice Swift enum back and forth where the compiler requires the original type. Swift Export currently lacks this “escape hatch.”

You can read more about SKIE enums here and Swift Export here.

Sealed Classes

Suppose we add a sealed class:

sealed class UserResult {
    data class Success(val user: String, val favoriteFood: String) : UserResult()
    data class Error(val message: String) : UserResult()
    data object NotFound : UserResult()
    
    internal data class InternalResult(val message: String) : UserResult()
}

class UsersService {
    fun findUser(id: Int): UserResult = if (id == 1) {
        UserResult.Success(user = "Carla", favoriteFood = "Hot dog")
    } else {
        UserResult.NotFound
    }
}

SKIE

public class UsersService : KotlinBase {

    open func findUser(id: Int32) -> UserResult
}

open class UserResult : KotlinBase {
}

extension UserResult {

    public class Error : UserResult {

        open var message: String { get }

        public init(message: String)

        open func doCopy(message: String) -> UserResult.Error

        open func isEqual(_ other: Any?) -> Bool

        open func hash() -> UInt

        open func description() -> String
    }

    public class NotFound : UserResult {

        open class var shared: UserResult.NotFound { get }

        public convenience init()

        // same isEqual, hash, description methods as before
        // however, a `doCopy` is not generated since this subclass is an `object` in Kotlin
    }

    public class Success : UserResult {

        open var user: String { get }

        public init(user: String)

        // same doCopy, isEqual, hash, description methods as before
    }
}

extension Skie.kmp_interop_demo__shared.UserResult {

    @frozen public enum __Sealed : Hashable {

        case error(UserResult.Error)

        case notFound(UserResult.NotFound)

        case success(UserResult.Success)
    }
}

Swift Export

final public class UsersService : KotlinBase {

    public func findUser(id: Int32) -> ExportedKotlinPackages.com.example.UserResult
}

open class UserResult : KotlinBase {

    final public class Error : ExportedKotlinPackages.com.example.UserResult {

        public var message: String { get }

        public init(message: String)

        public static func == (this: ExportedKotlinPackages.com.example.UserResult.Error, other: (any KotlinRuntimeSupport._KotlinBridgeable)?) -> Bool

        public func copy(message: String) -> ExportedKotlinPackages.com.example.UserResult.Error

        public func equals(other: (any KotlinRuntimeSupport._KotlinBridgeable)?) -> Bool

        public func hashCode() -> Int32

        public func toString() -> String
    }

    final public class NotFound : ExportedKotlinPackages.com.example.UserResult {

        public static var shared: ExportedKotlinPackages.com.example.UserResult.NotFound { get }

        // same static func, equals, hashCode, toString methods as before
        // however, it does not generate a `copy` method because this is an `object` in Kotlin.
    }

    final public class Success : ExportedKotlinPackages.com.example.UserResult {

        public var user: String { get }

        public init(user: String)

        // same static func, copy, equals, hashCode, toString methods as before
    }
}

Once again, SKIE subclasses are all open while Swift Export makes them final, correctly reflecting Kotlin’s sealed class semantics.

Swift Export also preserves Kotlin method names (copy, equals, hashCode and toString) while SKIE uses doCopy, isEqual, hash and description.

SKIE ensures the most Swift-idiomatic way to consume a sealed class by generating onEnum(of:) + @frozen __Sealed enum with all associated values. It is exhaustive, compiler-enforced and associated values are extracted automatically.

Swift Export does no such thing so you must fall back to type casting (case let s as UserResult.Success: print(s.user)) and the default case is mandatory, so the compiler won’t catch a missing case if you add a new subclass in the future

Both do not expose hidden sealed classes (internal data class InternalResult).

SKIE’s onEnum turns sealed classes into exhaustive, type-safe Swift pattern matching. Swift Export exposes the class hierarchy but loses exhaustiveness, arguably defeating the main purpose of sealed classes.

You can read more about SKIE sealed class here and Swift Export here.

Global Functions

Now, let’s create a new file and name it Calculator, with the code below and compare the results:

package com.example

fun coolSum() = 2 + 2
val magicNumber = 3

SKIE

public func coolSum() -> Int32

public var magicNumber: Int32 { get }

public class CalculatorKt : KotlinBase {

    open class var magicNumber: Int32 { get }

    open class func coolSum() -> Int32
}

Swift Export

extension com.example {
    public static func coolSum() -> Int32
    public static var magicNumber: Int32 { get }
}

Both libraries add the global functions and properties at the root of the module file. The only main difference is that Swift Export scopes them under their specific Kotlin package namespace, while SKIE aggregates them into the Shared umbrella.

However, SKIE does something exceptionally smart for existing codebases:

Migration Win: A Structural Upgrade

Because SKIE’s purpose is to fundamentally reshape your Kotlin API into idiomatic Swift, adding it to an existing project will likely cause breaking changes. For example, if you previously wrote manual Swift wrappers to handle Kotlin Flows or suspend functions, SKIE’s automated versions will likely conflict with your custom code. However, this is a “good” break: it forces you to delete fragile, manual workarounds in favor of a compiler-guaranteed Swift interface. While you can’t just flip a switch and expect a massive codebase to compile instantly, the migration cost pays for itself by providing a much cleaner, more maintainable API for your iOS team.

Overloaded Functions

To test this, let’s add more functions to our UserService:

package com.example

class UsersService {
    fun getUserById(id: Int): String = "Carla"
    fun getUserById(id: String): String = "Carla"
}

SKIE

public class UsersService : KotlinBase {

    public init()

    open func getUserById(id: Int32) -> String

    open func getUserById(id: String) -> String
}

Swift Export

extension com.example {

    final public class UsersService : KotlinBase {

        public init()

        public func getUserById(id: Int32) -> String

        public func getUserById(id: String) -> String
    }
}

Both configurations handle overloads cleanly in Swift: Swift Export generates them natively, while SKIE “un-mangles” the underscores that the standard Objective-C bridge adds to satisfy the legacy runtime.

__attribute__((swift_name("UsersService")))
@interface SharedUsersService : SharedBase
- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((Obj-C_designated_initializer));
+ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead")));
- (NSString *)getUserByIdId:(int32_t)id __attribute__((swift_name("getUserById(id:)")));
- (NSString *)getUserByIdId_:(NSString *)id __attribute__((swift_name("getUserById(id_:)")));
@end

The last two lines are the important part we want to show you. Obj-C does not support overload, so SKIE has to hack around it by adding _ to the end of the function name. On the Swift code side of things we don’t see this thanks to the Swift overlay.

You can read more about how SKIE handles overloaded functions here.

Interface Extension

For this test, I created a new file with the following code:

package com.example

interface Describable {
    fun describe(): String
    fun describeUppercase(): String = describe().uppercase()
}

data class Product(val name: String, val price: Double) : Describable {
    override fun describe(): String = "$name costs $$price"
}

// Extension function
fun Product.discountedPrice(discount: Double): Double = price - (price * discount)

// Extension property
val Product.formattedPrice: String get() = "$$price"

Pure Obj-C Export

public protocol Describable {

    func describe() -> String

    func describeUppercase() -> String
}

extension Product {

    open var formattedPrice: String { get }

    open func discountedPrice(discount: Double) -> Double
}

public class Product : KotlinBase, Describable {

    open var name: String { get }

    open var price: Double { get }

    public init(name: String, price: Double)

    open func describe() -> String

    open func description() -> String

    // doCopy, isEqual, hash methods
}

Swift Export

extension _KotlinExistential : ExportedKotlinPackages.com.example.Describable where Wrapped : ExportedKotlinPackages.com.example._Describable {
}

    public protocol Describable : KotlinBase {

    func describe() -> String

    func describeUppercase() -> String
}

    final public class Product : KotlinBase, ExportedKotlinPackages.com.example.Describable {

    public var name: String { get }

    public var price: Double { get }

    public init(name: String, price: Double)

    public func describe() -> String

    // func, copy, equals, hashCode, toString methods
}

public static func discountedPrice(_ receiver: ExportedKotlinPackages.com.example.Product, discount: Double) -> Double

public static func getFormattedPrice(_ receiver: ExportedKotlinPackages.com.example.Product) -> String
  1. Obj-C bridge generates extension functions/properties as native Swift extensions: product.discountedPrice(discount: 0.1) and product.formattedPrice. Swift Export turns them into static functions with an explicit receiver: org.example.discountedPrice(product, discount: 0.5) and org.example.getFormattedPrice(product), very un-Swift-like.
  2. In both libraries describeUppercase() becomes a required protocol method instead of a default implementation. Any Swift type trying to conform to Describable would need to implement both methods manually.
  3. Even though it’s a rare scenario, Swift Export exposes an internal bridging detail for existential types (_KotlinExistential). It’s something to be aware if you have a function explicitly returning a Kotlin interface type.

While Obj-C bridge currently converts extension functions into native Swift extensions, Swift Export currently exports them as static functions with explicit receivers. It’s a noticeable gap in ergonomics today, though native extension support is a confirmed roadmap item for future Swift Export releases.

In practice, this is what I mean:

Obj-C Export

let product = Product(name: "Apple", price: 1.0)
let discountedPrice = product.discountedPrice(discount: 0.5)

Swift Export

let product = com.example.Product(name: "Apple", price: 1.0)
let discountedPrice = com.example.discountedPrice(product, discount: 0.5)

Flows

If there is one feature that drives iOS developers crazy when consuming Kotlin code, it’s Flow. Because Objective-C has no native equivalent to Kotlin’s asynchronous streams, a standard KMP export turns beautiful Kotlin Flows into clunky callback interfaces that require manual lifecycle management.

Let’s see how both plugins handle a typical StateFlow and a standard Flow:

// file: Flow.kt
class FlowService {
    private val _counter: MutableStateFlow<Int> = MutableStateFlow(0)
    val counter: StateFlow<Int> = _counter

    fun numberStream(): Flow<Int> = flow {
        for (i in 1..10) {
            delay(1.seconds)
            emit(i)
        }
    }
}

SKIE

public class FlowService : KotlinBase {

    open var counter: Shared.SkieSwiftStateFlow<KotlinInt> { get }

    // init methods

    open func numberStream() -> Shared.SkieSwiftFlow<KotlinInt>
}

public protocol SkieSwiftFlowProtocol<Element> : AsyncSequence {
    // body
}

final public class SkieSwiftMutableSharedFlow<T> : Shared.SkieSwiftFlowProtocol {
    // body
}

public func SkieKotlinFlow<T>(_ flow: Shared.SkieSwiftMutableSharedFlow<T>) -> SkieKotlinFlow<T> where T : AnyObject

// a ton of other bridging methods, including support for optional flows

Swift Export

public final class FlowService : KotlinRuntime.KotlinBase {
    public var counter: any KotlinCoroutineSupport.KotlinTypedStateFlow<Swift.Int32> { get }

    // init methods

    public func numberStream() -> any KotlinCoroutineSupport.KotlinTypedFlow<Swift.Int32>
}

extension KotlinTypedFlow {
    public func asAsyncSequence() -> KotlinFlowSequence<Element>
}

Looking at the generated code, we can see two distinct engineering philosophies achieving the exact same goal: bringing Kotlin’s asynchronous streams natively into Swift’s async/await ecosystem.

Native AsyncSequence Integration

Both tools successfully bypass the legacy Obj-C completion handler mess, letting you consume reactive streams using native Swift loops, but they differ slightly in implementation:

  • SKIE implicitly swaps Kotlin flow types for SkieSwift* wrapper objects that conforms natively to AsyncSequence.
  • Swift Export wraps your flow inside a generic KotlinTypedFlow wrapper. The companion KotlinCoroutinesSupport module then provides a protocol extension on KotlinTypedFlow which exposes the .asAsyncSequence() method.

Preserving Generic Types

Both tools preserve the types passed down by flows.

  • SKIE synthetizes entirely distinct types for flows and optionals (SkieSwiftOptionalFlow<T>) to guarantee exact type alignment.
  • Swift Export leverages native Swift generics directly, translating a Kotlin Flow<Int> into a strongly typed any KotlinCoroutineSupport.KotlinTypedFlow<Swift.Int32>.

Footprint vs Isolation

The biggest difference today between the tools is where the bridging code lives:

  • SKIE generates a massive amount of briding code directly inside your main framework, which is handy but can heavily inflate the API surface and auto-complete index with hundreds of global functions.
  • Swift Export offloads its entire asynchronous plumbing to an isolated helper module called KotlinCoroutinesSupport. This module stays completely out of your main API’s way while handling coroutines and flows cleanly under the hood.

The Code in Practice

The developer experience is now really close. Here’s a basic example of how to consume a Kotlin Flow using each approach:

Using SKIE

for await number in flowService.numberStream() {
    print(number)
}

Using Swift Export

for await number in flowService.numberStream().asAsyncSequence() {
    print(number)
}

Given that both approaches have type safety and seamlessly integrate with Flows, the only difference now is that SKIE is slightly less verbose, though the .asAsyncSequence() explicit call is a deliberate design choice by JetBrains: “cold Flows were never designed to be iterable out of the box”.

The Takeaway

This used to be one of SKIE’s strongest points, but with the alpha release of Swift Export, the landscape for concurrency interop has shifted into near-parity since both approaches provide the same benefits.

You are not stuck to one or the other anymore in regards to concurrency, so your decision is now down to whether you prefer SKIE’s syntactic automation or Swift Export’s modular isolation.

The Production Reality (Where SKIE Still Dominates)

Generic Type Parameters

Heres the result given the code below:

// file: GenericBox.kt
package com.example

class Box<T>(val value: T)

fun <T> createBox(value: T): Box<T> = Box(value)

Pure Obj-C Export

public class GenericBoxKt : KotlinBase {

    open class func createBox(value: Any?) -> Box<AnyObject>
}

public class Box<T> : KotlinBase where T : AnyObject {

    open var value: T? { get }

    public init(value: T?)
}

public func createBox(value: Any?) -> Box<AnyObject>

Swift Export

final public class Box : KotlinBase {

    public var value: (any KotlinRuntimeSupport._KotlinBridgeable)? { get }

    public init(value: (any KotlinRuntimeSupport._KotlinBridgeable)?)
}

public static func createBox(
    value: (any KotlinRuntimeSupport._KotlinBridgeable)?
) -> ExportedKotlinPackages.com.example.Box
  1. Obj-C export has support for generic types, though it erases the type. value comes back as Any? and requires manual casting.
  2. Currently, Swift Export does not support generic types. The _KotlinBridgeable constraint means only Kotlin objects (classes inheriting from KotlinBase, like Product, since KotlinBase conforms to the _KotlinBridgeable protocol) can be used as type parameters. Swift value types are excluded entirely.

The Takeaway

Generics remain a complex area for KMP: while the Obj-C bridge and SKIE inherit the type erasure behavior of their underlying languages, Swift Export is currently more restrictive and can fail to compile for certain non-Kotlin type parameters.

The Verdict

Because Swift Export and the Objective-C export (which SKIE relies on) are mutually exclusive, they cannot be mixed in the same project, choosing between them is an all-or-nothing architectural decision.

Like everything else in tech, the answer is always: it depends. But here is a pragmatic breakdown of when to choose which:

Native iOS UI

If you are building native iOS views, then you should stick to SKIE. Native iOS developers rely heavily on exhaustive switch statements for state management, for await loops for observing Flows, and extension functions for domain logic. Because Swift Export still degrades extensions into static functions and is actively iterating on advanced type mappings, SKIE remains the choice that will keep your native iOS engineers the happiest today.

Sharing UI with Compose Multiplatform

If you are using Compose Multiplatform, your Kotlin-to-Swift interop surface is usually just a few lines of setup code, giving you much more leeway to choose. However, given that SKIE is significantly more mature than Swift Export, you’ll want to stick with it for production code to avoid unexpected edge cases. Swift Export may be best suited for personal projects or small-scope MVPs.

Final Thoughts

Swift Export’s recent features prove that a cleaner, Objective-C-free architecture is actively arriving, it currently lacks the necessary ergonomics for complex iOS apps. Yet, SKIE remains the definitive choice for teams who need total language ergonomics, default interface implementations, and exhaustive pattern matching right this second.

The main takeaway is that Kotlin Multiplatform is already a mature, stable technology today. By pairing the standard Obj-C export with SKIE, you get a robust, production-ready solution right now, whether you are building a greenfield app or migrating a legacy one. As Swift Export matures, the ecosystem will naturally evolve, but for the foreseeable future, SKIE remains the definitive, confident choice for shipping KMP on iOS.

Previous