Kotlin for Swift Developers

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

If you write Swift every day, Kotlin will feel familiar within an afternoon. Both languages are statically typed, compiled, null-safe, expression-oriented, and built around value-like data, protocols or interfaces, extensions, closures and structured concurrency. The differences that matter are few, but they are exactly the ones that bite when you move business logic into a shared Kotlin module and then consume it from SwiftUI. This page is a mapping, not a tutorial: for every Swift concept it shows the Kotlin counterpart, the usual trap, and what the same declaration looks like once Kotlin/Native has exported it to Swift.

The page targets the section baseline: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4, Ktor 3.6.0, SQLDelight 2.4.0, Koin 4.2.2, SKIE 0.10.15 and KMP-NativeCoroutines 1.0.6. Stability is stated where it is not Stable: Swift export is Alpha, SwiftPM import is Alpha, @ObjCName requires an opt-in (Experimental), and SKIE and KMP-NativeCoroutines are third-party tools with 0.x or 1.0.x versions rather than JetBrains-stabilized features. Examples are slices of the running example, TrailMate Everywhere (package com.example.trailmate), introduced in What Kotlin Multiplatform Is and When to Use It.

The Kotlin language is not re-taught here. Each section links the matching page of the Kotlin Reference and, on the Swift side, the matching page of the Swift Reference and the Apple Platforms guides. Read Apple Multiplatform Projects for how an Xcode project consumes a shared framework, and Language Interoperability for the Objective-C and Swift bridge in general.

The Mental Model: Same Ideas, Different Defaults

Four defaults differ, and most of this page follows from them. Swift struct and enum are value types while every Kotlin user-defined type is a reference type (immutability is by convention with val and copy()). Swift uses ARC, while Kotlin has a tracing garbage collector on every target. Swift throws is part of the signature, while Kotlin exceptions are unchecked and @Throws only affects the Objective-C/Swift export. Swift 6 checks data races at compile time, while Kotlin relies on coroutines, dispatchers and the Kotlin/Native memory manager.

For the details of each language, see Swift: Structures and Classes and Kotlin: Classes and Objects. The Swift code a shared module exposes is Objective-C-shaped today (Swift export, which maps Kotlin to Swift directly, is Alpha), so the export sections below describe both views: the Kotlin source and the Swift you will actually call.

Optionals and Nullable Types

Swift Optional<T> is an enum with two cases; Kotlin T? is a type-system feature with no wrapper object. The operators correspond almost one-to-one.

Swift Kotlin Notes

var photoUrl: String?

val photoUrl: String?

Kotlin nullable types are compile-time only; no boxing for reference types.

trail?.name

trail?.name

Optional chaining and the safe-call operator are the same token.

trail?.name ?? "Unknown"

trail?.name ?: "Unknown"

Nil-coalescing ?? is the Elvis operator ?:.

trail!.name

trail!!.name

Force unwrap; throws on null in both languages. Avoid in shared code.

if let name = trail?.name \{ …​ }

trail?.name?.let \{ name → …​ }

Or rely on smart casts: if (trail != null) \{ trail.name }.

guard let trail else \{ return }

val t = trail ?: return

The Elvis operator can return or throw on its right side.

Kotlin has smart casts: after a null check the compiler treats a val (or a local var that is not captured and modified) as non-null, so no unwrapping step exists.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/TrailFormatting.kt
fun Trail.subtitle(): String {
    // photoUrl is String? -- the Elvis operator supplies a default
    val picture = photoUrl ?: "no photo"
    return "$name ($region) - $picture"
}

fun describe(trail: Trail?): String {
    if (trail == null) return "No trail selected"
    // smart cast: trail is Trail from here on, no unwrapping
    return "${trail.name}: ${trail.lengthKm} km"
}

What crosses the Swift boundary: Kotlin String? becomes Swift String?, a Kotlin Trail? becomes Trail?, but a nullable primitive such as Int? becomes KotlinInt? (a boxed NSNumber subclass), and a Long becomes Int64, a Double becomes Double. Generic collections of primitives, such as List<Int>, arrive as [KotlinInt]; call .int32Value or .intValue to read them. Design shared APIs with non-null String, Int and Boolean where you can, to keep the Swift side clean.

Struct versus Data Class

Swift struct gives you value semantics for free: assignment copies, let makes it deeply immutable, and Equatable/Hashable are synthesized. Kotlin data class gives you the API of a value type — equals, hashCode, toString, copy and destructuring — on an ordinary reference type.

Swift Kotlin Notes

struct Trail: Equatable, Hashable \{ …​ }

data class Trail(…​)

Equality and hashing are generated from the primary-constructor properties.

let properties

val properties

Prefer val everywhere; a data class with var properties is a code smell.

var copy = trail; copy.isFavorite = true

val copy = trail.copy(isFavorite = true)

copy() takes named arguments with default values equal to the current ones.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/Trail.kt
package com.example.trailmate.domain

enum class Difficulty { EASY, MODERATE, HARD }

data class Trail(
    val id: String,
    val name: String,
    val region: String,
    val lengthKm: Double,
    val difficulty: Difficulty,
    val photoUrl: String?,
    val isFavorite: Boolean = false,
)

fun Trail.toggleFavorite(): Trail = copy(isFavorite = !isFavorite)
// iosApp/iosApp/Trails/FavoriteToggle.swift
import SharedLogic

func toggled(_ trail: Trail) -> Trail {
    // Kotlin data class exported as a final Objective-C class with a copy method
    trail.doCopy(
        id: trail.id,
        name: trail.name,
        region: trail.region,
        lengthKm: trail.lengthKm,
        difficulty: trail.difficulty,
        photoUrl: trail.photoUrl,
        isFavorite: !trail.isFavorite
    )
}

The Swift view is the first surprise. The Objective-C export names the Kotlin copy as doCopy (because copy is an NSObject method), requires every argument, and loses the default values, so the Kotlin copy(isFavorite = …​) shortcut does not exist in Swift. Put such helpers in Kotlin (toggleFavorite() above) and call them from Swift as TrailFormattingKt.toggleFavorite(trail)-style top-level functions, rather than rebuilding the logic in Swift. The exported class also uses reference identity, so Swift == on exported Kotlin classes should be written as trail.isEqual(other) unless you add Equatable conformance in a Swift extension.

Copy-on-write is the other difference: Swift collections are value types, so mutating a copy never affects the original. Kotlin’s read-only List<Trail> is only a view; a MutableList can be hiding behind it. Treat shared state as immutable and replace it with a new instance, which is exactly what StateFlow<TrailListUiState> expects.

Protocols versus Interfaces

A Swift protocol and a Kotlin interface both declare a contract that classes, structs or enums adopt. Kotlin interfaces can carry default implementations and (abstract) properties, much like protocol extensions.

Swift Kotlin Notes

protocol TrailRepository \{ …​ }

interface TrailRepository \{ …​ }

Both are the dependency-inversion seam for tests and DI.

class Foo: Bar

class Foo : Bar

A class implements any number of interfaces; extends at most one class.

Protocol extension with default

Method body inside the interface

Kotlin defaults are part of the interface, overridable per implementor.

any TrailRepository / some TrailRepository

TrailRepository

There is no existential/opaque split; Kotlin interface types are always dynamic.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/TrailRepository.kt
package com.example.trailmate.data

import com.example.trailmate.domain.Trail
import kotlinx.coroutines.flow.Flow

interface TrailRepository {
    fun observeTrails(region: String? = null): Flow<List<Trail>>
    suspend fun refresh(region: String? = null)
    suspend fun setFavorite(id: String, favorite: Boolean)

    // default implementation, like a protocol extension
    suspend fun toggleFavorite(trail: Trail) = setFavorite(trail.id, !trail.isFavorite)
}
// iosApp/iosApp/Trails/PreviewTrailRepository.swift
import SharedLogic

// A Swift class can implement an exported Kotlin interface (an Objective-C protocol)
final class PreviewTrailRepository: NSObject, TrailRepository {
    func setFavorite(id: String, favorite: Bool) async throws { }
    func refresh(region: String?) async throws { }
}

Swift classes can implement exported Kotlin interfaces, and that is the clean way to inject a platform capability (for example a Swift IosLocationProvider into shared code), but they must inherit NSObject and handle the Flow and suspend shapes shown below. Default arguments (region: String? = null) are not exported, so Swift callers pass every argument. See expect/actual and Calling Platform APIs for the LocationProvider pattern.

Extensions

Both languages add functions and computed properties to types you do not own, resolved statically. Kotlin extension functions are plain top-level functions with a receiver; they cannot add stored properties, and they cannot override a member (a member always wins).

Swift Kotlin Notes

extension Trail \{ var label: String \{ …​ } }

val Trail.label: String get() = …​

Extension properties have no backing field in either language.

extension Trail \{ func km() → String \{ …​ } }

fun Trail.km(): String = …​

Declared at top level; imported by name (import com.example.trailmate.domain.km).

extension Array where Element == Trail

fun List<Trail>.favorites() = filter \{ it.isFavorite }

Constrain by writing the concrete type argument as the receiver.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/TrailExtensions.kt
package com.example.trailmate.domain

val Trail.isLong: Boolean get() = lengthKm >= 15.0

fun List<Trail>.favorites(): List<Trail> = filter { it.isFavorite }

The Objective-C export has no concept of an extension, so Swift calls TrailExtensionsKt.favorites(trails): Kotlin extension functions appear as static methods on a class named after the file (TrailExtensionsKt), with the receiver as the first parameter. Use @file:JvmName for the JVM, and @ObjCName (Experimental, needs @OptIn(ExperimentalObjCName::class)) to give the exported class or function a Swift-friendly name. To get real Swift extensions, write them in Swift on top of the exported types.

Enums and Sealed Hierarchies

Swift enums with associated values do the job of both Kotlin’s enum class (a fixed set of constants) and Kotlin’s sealed classes and interfaces (a closed set of shapes that can carry data).

Swift Kotlin Notes

enum Difficulty \{ case easy, moderate, hard }

enum class Difficulty \{ EASY, MODERATE, HARD }

Kotlin constants are objects and conventionally upper case; members can have properties and methods.

enum Result \{ case loading; case success([Trail]); case failure(String) }

sealed interface TrailListUiState

Each case is a nested data class or data object implementing the sealed type.

switch state \{ case .loading: …​ }

when (state) \{ is Loading → …​ }

when is exhaustive for enums and sealed types when used as an expression or statement on them.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/TrailListUiState.kt
package com.example.trailmate.presentation

import com.example.trailmate.domain.Trail

sealed interface TrailListUiState {
    data object Loading : TrailListUiState
    data class Success(val trails: List<Trail>) : TrailListUiState
    data class Error(val message: String) : TrailListUiState
}

fun TrailListUiState.title(): String = when (this) {
    TrailListUiState.Loading -> "Loading"
    is TrailListUiState.Success -> "${trails.size} trails"
    is TrailListUiState.Error -> "Error: $message"
}
// iosApp/iosApp/Trails/TrailListView.swift
import SwiftUI
import SharedLogic

struct StateBanner: View {
    let state: TrailListUiState

    var body: some View {
        // Without SKIE the sealed interface is an Objective-C protocol: no exhaustive switch.
        if state is TrailListUiStateLoading {
            ProgressView()
        } else if let success = state as? TrailListUiStateSuccess {
            Text("\(success.trails.count) trails")
        } else if let error = state as? TrailListUiStateError {
            Text(error.message)
        }
    }
}

Plain Objective-C export turns a sealed interface into a protocol plus unrelated classes, so Swift sees an open hierarchy and cannot check exhaustiveness. SKIE 0.10.15 (Touchlab, third-party, pre-1.0) generates an onEnum(of:) function that returns a real Swift enum, restoring switch exhaustiveness for sealed hierarchies and exported enums:

// iosApp/iosApp/Trails/TrailListView.swift (with SKIE)
switch onEnum(of: state) {
case .loading: ProgressView()
case .success(let success): Text("\(success.trails.count) trails")
case .error(let error): Text(error.message)
}

Enums are exported as Objective-C classes with static members (Difficulty.easy), so a Swift switch over a Kotlin enum is not exhaustive without SKIE either; Swift export (Alpha) maps Kotlin enums to real Swift enums.

Closures versus Lambdas

A Swift closure and a Kotlin lambda are the same idea with different punctuation. Kotlin adds an implicit it for single-parameter lambdas and trailing lambda syntax, the equivalent of Swift’s trailing closure.

Swift Kotlin Notes

\{ (a: Int, b: Int) → Int in a + b }

\{ a: Int, b: Int → a + b }

Kotlin puts parameters before → inside the braces.

(Int) → String

(Int) → String

Same function type syntax.

trails.map \{ $0.name }

trails.map \{ it.name }

filter, map, flatMap, sorted(by:) ⇒ filter, map, flatMap, sortedBy.

@MainActor () → Void

suspend () → Unit run in Dispatchers.Main

Threading is chosen by dispatcher, not annotation.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/TrailSearch.kt
package com.example.trailmate.domain

fun List<Trail>.search(query: String): List<Trail> =
    filter { it.name.contains(query, ignoreCase = true) }
        .sortedBy { it.lengthKm }

fun <T> List<T>.countWhere(predicate: (T) -> Boolean): Int = count(predicate)

A Kotlin function type is exported as an Objective-C block. Generic parameters erase to Any and a primitive Boolean return needs boxing (KotlinBoolean), so Swift callers cast and box. Prefer exporting concrete, non-generic functions for the Swift side. Kotlin Unit becomes Void in Objective-C blocks (KotlinUnit for generics).

async/await versus suspend

Swift marks a function async and calls it with await; Kotlin marks it suspend and calls it from another suspending function or a coroutine. Both are cooperative: the function can pause without blocking its thread.

Swift Kotlin Notes

func load() async throws → [Trail]

suspend fun load(): List<Trail>

No throws in the signature; exceptions propagate.

try await load()

load()

Suspension points are not marked at the call site; the IDE shows a gutter icon.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/OfflineFirstTrailRepository.kt
suspend fun refresh(region: String?) = coroutineScope {
    val remote = async { api.trails(region) }   // like Swift's async let
    db.replaceAll(remote.await())
}

The same declaration looks different once exported (the snippet is a slice of OfflineFirstTrailRepository, which implements TrailRepository). A Kotlin suspend fun becomes an Objective-C method with a completionHandler, and Swift imports completion-handler methods as async throws automatically:

// iosApp/iosApp/Trails/LoadTrails.swift
import SharedLogic

func loadTrails(repository: TrailRepository) async {
    do {
        // Kotlin: suspend fun refresh(region: String?)
        try await repository.refresh(region: nil)
    } catch {
        print("Refresh failed: \(error)")
    }
}

Two caveats apply. First, a Kotlin suspend function only surfaces as a Swift throws when it is annotated with @Throws (next-but-one section); without it an uncaught Kotlin exception in a suspend function terminates the app unless it is a CancellationException. Second, cancelling the Swift Task does not always cancel the underlying coroutine in plain Objective-C export; KMP-NativeCoroutines 1.0.6 (third-party) and SKIE (which maps suspend to a cancellable Swift async function) fix this. See Coroutines, Flow and Concurrency in Shared Code for dispatchers and structured concurrency, and Objective-C Interop, Swift Export and SKIE for the export rules in detail and Consuming Coroutines and Flow from Swift and SwiftUI for flows.

Combine and AsyncSequence versus Flow

A Kotlin Flow<T> is a cold asynchronous stream, closest to Swift’s AsyncSequence (pull-style, structured) and to a Combine publisher (operators such as map, combine, debounce). StateFlow<T> is the "current value plus changes" type, closest to CurrentValueSubject or an @Published property; SharedFlow<T> is closest to PassthroughSubject.

Swift Kotlin Notes

AsyncSequence / AsyncStream<T>

Flow<T> / flow \{ emit(x) }

Cold: nothing runs until collected.

CurrentValueSubject<T, Never> / @Published

MutableStateFlow<T> / StateFlow<T>

Always has a value; conflates equal values.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/TrailListViewModel.kt
package com.example.trailmate.presentation

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.trailmate.data.TrailRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

class TrailListViewModel(repository: TrailRepository) : ViewModel() {
    private val query = MutableStateFlow("")

    val uiState: StateFlow<TrailListUiState> =
        combine(repository.observeTrails(), query) { trails, q ->
            TrailListUiState.Success(trails.filter { it.name.contains(q, ignoreCase = true) })
        }
            .map<TrailListUiState.Success, TrailListUiState> { it }
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TrailListUiState.Loading)

    fun onQueryChange(value: String) { query.value = value }
}

Swift cannot call collect on a plain exported Flow ergonomically: it receives a Kotlinx_coroutines_coreFlow protocol with a callback-based collect(collector:completionHandler:), generic types erased to Any. The two community solutions turn flows into native Swift types:

// iosApp/iosApp/Trails/TrailListScreen.swift (KMP-NativeCoroutines)
import KMPNativeCoroutinesAsync
import SharedLogic
import SwiftUI

struct TrailListScreen: View {
    let viewModel: TrailListViewModel
    @State private var state: TrailListUiState = TrailListUiStateLoading()

    var body: some View {
        StateBanner(state: state)
            .task {
                do {
                    // @NativeCoroutinesState on uiState generates uiStateFlow
                    for try await value in asyncSequence(for: viewModel.uiStateFlow) {
                        state = value
                    }
                } catch { print("collection failed: \(error)") }
            }
    }
}

With SKIE no annotation is needed: Flow is exported as a Swift AsyncSequence, so the same loop reads for await value in viewModel.uiState, and StateFlow additionally exposes a synchronous .value. Pick one tool per project; both are third-party (SKIE 0.10.15, KMP-NativeCoroutines 1.0.6). The project-wide choice is discussed in Objective-C Interop, Swift Export and SKIE; the SwiftUI side of state is covered in SwiftUI State and Data Flow.

Swift Package Manager versus Gradle

SwiftPM manages Swift packages with a Package.swift manifest; Xcode builds the app. In a KMP project Gradle builds the Kotlin modules and hands Xcode a framework (or a SwiftPM package of one).

SwiftPM / Xcode Gradle Notes

Package.swift

build.gradle.kts + settings.gradle.kts

Gradle files are Kotlin code (the Kotlin DSL).

Target / product

Gradle module / source set / Kotlin target

sharedLogic is one module with iosArm64(), iosSimulatorArm64() and more.

.package(url:, from:)

implementation(libs.ktor.client.core)

Dependencies resolve from Maven Central and Google Maven, not git tags.

// sharedLogic/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
    android { namespace = "com.example.trailmate.shared"; compileSdk = 36; minSdk = 24 }
    jvm()
    val xcf = org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework("SharedLogic")
    listOf(iosArm64(), iosSimulatorArm64()).forEach {
        it.binaries.framework {
            baseName = "SharedLogic"   // the Swift module name: import SharedLogic
            isStatic = true
            xcf.add(this)
        }
    }
    sourceSets.commonMain.dependencies {
        implementation(libs.kotlinx.coroutines.core)
        implementation(libs.ktor.client.core)
    }
}

Three directions exist for combining the two build systems: Gradle builds a framework that Xcode embeds directly (embedAndSignAppleFrameworkForXcode), Gradle publishes an XCFramework that a Swift package references as a binary target, and Gradle consumes Swift packages through SwiftPM import (Alpha). They are detailed in Gradle Configuration, the Multiplatform DSL and Compatibility and Dependencies, klibs.io, cinterop and SwiftPM Import. For SwiftPM itself, see Swift Package Manager and Swift Packages and Modularization.

Access Control

The two systems have similar levels but different defaults and different module boundaries.

Swift Kotlin Notes

internal (the default)

public (the default)

The defaults are opposite: unmarked Kotlin declarations are visible to every consumer of the module.

public / open

public / open

Kotlin classes and members are final unless marked open or abstract.

internal

internal

Visible within the same Gradle module (all source sets of one project).

fileprivate

private (top-level)

A top-level private declaration is visible only in its file.

private

private

Private to the enclosing class.

package

no equivalent

Use a separate Gradle module with internal for the closest approximation.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/TrailMapper.kt
package com.example.trailmate.data

import com.example.trailmate.domain.Difficulty
import com.example.trailmate.domain.Trail
import kotlin.experimental.ExperimentalObjCRefinement

internal fun TrailDto.toDomain(): Trail = Trail(
    id = id, name = name, region = region, lengthKm = lengthKm,
    difficulty = Difficulty.valueOf(difficulty), photoUrl = photoUrl, isFavorite = false,
)

// Visible to Kotlin consumers but hidden from the Objective-C/Swift API
@OptIn(ExperimentalObjCRefinement::class)
@HiddenFromObjC
fun debugDump(trail: Trail): String = trail.toString()

Only public declarations of the module whose framework you build — and of modules you export() — reach Swift; internal and private never appear in the header. Use internal heavily in shared modules, and use api versus implementation dependencies deliberately, since an implementation dependency’s types do not leak into the framework unless exported. @HiddenFromObjC is Experimental (opt-in with ExperimentalObjCRefinement) and removes an otherwise public declaration from the header; @ShouldRefineInSwift goes further and makes it a __-prefixed member that you re-expose with a nicer Swift wrapper.

throws versus Exceptions and @Throws

Swift’s throws is checked by the compiler. Kotlin exceptions are all unchecked; there is no throws clause and no forced try. That difference is the most dangerous one at the Swift boundary because Objective-C has no exception model that Swift can catch.

Swift Kotlin Notes

func f() throws

fun f() (no marker)

Exceptions are not part of the signature.

try f() / do \{ } catch \{ }

try \{ f() } catch (e: IOException) \{ }

try is an expression in Kotlin: val x = try \{ …​ } catch (e: Exception) \{ null }.

Error protocol / enums

Throwable, Exception subclasses

Model failures as sealed classes when callers must handle them.

When Kotlin code is called from Swift, an exception that escapes is not a Swift error. For ordinary functions it crashes the app. For functions annotated with @Throws(SomeException::class) the exported method becomes a Swift throws function, and listed exception classes (and their subclasses) are converted to NSError; any other exception still terminates the process. For suspend functions, CancellationException is always forwarded to the completion handler, and @Throws is needed for the rest.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/TrailApiClient.kt
package com.example.trailmate.data

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import kotlinx.io.IOException

class TrailNotFoundException(val id: String) : Exception("Trail $id not found")

class TrailApiClient(private val client: HttpClient) {
    @Throws(IOException::class, TrailNotFoundException::class, kotlin.coroutines.cancellation.CancellationException::class)
    suspend fun trail(id: String): TrailDto {
        val dto: TrailDto? = client.get("https://api.example.com/v1/trails/$id").body()
        return dto ?: throw TrailNotFoundException(id)
    }

    @Throws(IOException::class, kotlin.coroutines.cancellation.CancellationException::class)
    suspend fun trails(region: String? = null): List<TrailDto> =
        client.get("https://api.example.com/v1/trails") { parameter("region", region) }.body()
}
// iosApp/iosApp/Trails/FetchTrail.swift
import SharedLogic

func fetch(api: TrailApiClient) async {
    do {
        let dto = try await api.trail(id: "t-42")
        print(dto.name)
    } catch let error as NSError {
        // Kotlin exception arrives wrapped; the original is in userInfo["KotlinException"]
        if let kotlinException = error.userInfo["KotlinException"] as? KotlinThrowable {
            print(kotlinException.message ?? "Unknown error")
        }
    }
}

The more robust pattern for shared code is not to throw across the boundary at all: return a sealed result type (for example sealed interface LoadResult with Success and Failure cases, or TrailListUiState.Error shown earlier) and let Swift pattern-match it. The error-modelling guidance, including which exceptions to list, is in Architecture, Shared ViewModels and State.

Common Mistakes

  • Expecting struct semantics from data class. A shared data class is a reference; mutate nothing and use copy() or helpers.

  • Forgetting @Throws on suspend functions that can fail, then wondering why the app terminates on a network error.

  • Writing switch over sealed types in Swift without SKIE. It falls back to as? chains with no compile-time exhaustiveness.

  • Assuming Swift Task cancellation stops a Kotlin coroutine in plain Objective-C export; use SKIE or KMP-NativeCoroutines.

  • Assuming Swift export is ready. It is Alpha; keep Objective-C export plus SKIE for production.