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 |
|---|---|---|
|
|
Kotlin nullable types are compile-time only; no boxing for reference types. |
|
|
Optional chaining and the safe-call operator are the same token. |
|
|
Nil-coalescing |
|
|
Force unwrap; throws on null in both languages. Avoid in shared code. |
|
|
Or rely on smart casts: |
|
|
The Elvis operator can |
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"
}
See Kotlin docs — Null safety and The Swift Programming Language — The Basics for the full rules, and Kotlin: Null Safety and Swift: Optionals in this repository.
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 |
|---|---|---|
|
|
Equality and hashing are generated from the primary-constructor properties. |
|
|
Prefer |
|
|
|
// 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 |
|---|---|---|
|
|
Both are the dependency-inversion seam for tests and DI. |
|
|
A class implements any number of interfaces; extends at most one class. |
Protocol extension with default |
Method body inside the |
Kotlin defaults are part of the interface, overridable per implementor. |
|
|
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 properties have no backing field in either language. |
|
|
Declared at top level; imported by name ( |
|
|
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 |
|---|---|---|
|
|
Kotlin constants are objects and conventionally upper case; members can have properties and methods. |
|
|
Each case is a nested |
|
|
|
// 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 |
|---|---|---|
|
|
Kotlin puts parameters before |
|
|
Same function type syntax. |
|
|
|
|
|
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 |
|---|---|---|
|
|
No |
|
|
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 |
|---|---|---|
|
|
Cold: nothing runs until collected. |
|
|
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 |
|---|---|---|
|
|
Gradle files are Kotlin code (the Kotlin DSL). |
Target / product |
Gradle module / source set / Kotlin target |
|
|
|
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 |
|---|---|---|
|
|
The defaults are opposite: unmarked Kotlin declarations are visible to every consumer of the module. |
|
|
Kotlin classes and members are |
|
|
Visible within the same Gradle module (all source sets of one project). |
|
|
A top-level |
|
|
Private to the enclosing class. |
|
no equivalent |
Use a separate Gradle module with |
// 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 |
|---|---|---|
|
|
Exceptions are not part of the signature. |
|
|
|
|
|
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
structsemantics fromdata class. A shareddata classis a reference; mutate nothing and usecopy()or helpers. -
Forgetting
@Throwson suspend functions that can fail, then wondering why the app terminates on a network error. -
Writing
switchover sealed types in Swift without SKIE. It falls back toas?chains with no compile-time exhaustiveness. -
Assuming Swift
Taskcancellation 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.