Kotlin Multiplatform allows developers to share business logic between Android and iOS applications while keeping the user interface native to each platform. A team can write networking, validation, data models, and business rules in Kotlin, then use that code from Swift in an iOS application.
The shared code may work correctly on Android but still cause problems when exposed to Swift. Kotlin and Swift have different type systems, naming conventions, error-handling mechanisms, and concurrency models. A Kotlin API that looks simple inside a shared module may appear unfamiliar or difficult to use from an iOS target.
Most interoperability issues become easier to manage when developers understand how Kotlin declarations are exported to Objective-C and Swift, how asynchronous operations cross the language boundary, and which APIs should be designed specifically for iOS consumers.
This article explains the common problems and shows practical ways to build a more predictable Kotlin-to-Swift integration.
Understanding How Kotlin Multiplatform Exposes Code to Swift
In a typical Kotlin Multiplatform project, shared code is placed in the commonMain source set, while iOS-specific code can live in source sets such as iosMain.
For example, a shared module might contain a simple service:
// commonMain/kotlin/com/example/shared/GreetingService.kt
package com.example.shared
class GreetingService {
fun greeting(name: String): String {
return "Hello, $name!"
}
}
The exact setup for exposing this class depends on the project's Kotlin Multiplatform configuration and the framework produced for iOS.
In a common framework-based setup, Kotlin declarations are exposed through generated Objective-C-compatible interfaces. Swift can then import the generated framework and call the exported APIs.
A Swift call might look like this:
import Shared
let service = GreetingService()
let message = service.greeting(name: "Alex")
print(message)
The precise names and method signatures depend on the framework name, export configuration, and generated bindings. Inspect the generated framework interface when the API exposed to Swift differs from what you expected.
Kotlin Multiplatform also has evolving Swift interoperability capabilities. The available options depend on the Kotlin version and project configuration, so avoid assuming that every project uses the same export mechanism.
Problem 1: Kotlin Types Do Not Always Map Directly to Swift Types
Kotlin and Swift provide similar basic types, but their type systems are not identical.
For example, Kotlin distinguishes between nullable and non-nullable types:
fun findUserName(id: String): String?
The return value can be a string or null.
When exposed through Objective-C-compatible bindings, this nullable value may appear as an optional type in Swift. However, the exact generated declaration can depend on the export mechanism and Kotlin version.
Swift code should handle the possibility of a missing value:
let userName = userService.findUserName(id: "user-42")
if let userName = userName {
print("User: \(userName)")
} else {
print("User not found")
}
The important point is that a Kotlin declaration should communicate its actual nullability. Returning an empty string to represent a missing value may hide the difference between a valid empty result and an absent result.
Handling Nullable Collections and Nested Types
Collections can be more complicated when they contain nullable values.
Consider this Kotlin function:
fun getDisplayNames(): List<String?> {
return listOf("Alex", null, "Jordan")
}
The list itself is non-null, but individual elements may be null.
When this API crosses the language boundary, the resulting Swift representation may not be as convenient as a native [String?]. The generated interface should be inspected to determine the actual types.
For APIs intended for frequent Swift use, consider returning simple, predictable models and minimizing deeply nested nullable structures.
For example, if missing names should never be included in the result, filter them in Kotlin:
fun getAvailableDisplayNames(): List<String> {
return listOf("Alex", null, "Jordan")
.filterNotNull()
}
This gives Swift a simpler contract: the collection contains only available names.
Problem 2: Kotlin Naming Conventions Can Produce Unexpected Swift APIs
Kotlin commonly uses properties and functions that follow Kotlin naming conventions. When these declarations are exported to Objective-C-compatible interfaces, their representation may differ from the API a Swift developer would naturally write.
For example:
class UserProfile {
var displayName: String = ""
fun updateDisplayName(value: String) {
displayName = value
}
}
The generated interface determines how Swift reads the property and invokes the method. It is better to inspect that interface than to assume that every Kotlin property becomes a native Swift property with identical behavior.
Design APIs for Both Languages
Shared code should not become unnecessarily complicated just to imitate Swift naming conventions. However, public APIs should remain straightforward for both Kotlin and Swift consumers.
A useful approach is to expose business-focused operations rather than a large collection of low-level implementation details.
For example, instead of requiring Swift to call several methods to prepare a profile, the shared module can provide one operation:
class UserProfileService {
fun createDisplayName(
firstName: String,
lastName: String
): String {
return listOf(firstName.trim(), lastName.trim())
.filter { it.isNotEmpty() }
.joinToString(" ")
}
}
Swift can then use the operation without needing to understand the internal validation steps.
If the generated API still feels awkward, consider introducing a small iOS-facing wrapper rather than redesigning the entire shared domain model around one platform.
Problem 3: Kotlin Exceptions Do Not Behave Like Swift Errors
Kotlin and Swift use different error-handling models.
Swift commonly uses throws and do-catch:
do {
let result = try service.loadProfile()
print(result)
} catch {
print("Unable to load profile: \(error)")
}
Kotlin typically uses exceptions, but Kotlin functions do not declare checked exceptions in the same way that some other languages do.
When a Kotlin API is exported through an Objective-C-compatible framework, its exception behavior depends on the exported declaration and interoperability configuration. A Kotlin exception should not be assumed to appear as a Swift throws function automatically.
For example, a Kotlin service might look like this:
class ProfileService {
fun loadProfile(): String {
return "Profile loaded"
}
}
If the implementation can fail, simply throwing an exception may not provide the Swift caller with the error-handling interface it expects.
Make Error Contracts Explicit
For operations where failures are expected, a shared result model can make the contract clearer.
sealed class ProfileResult {
data class Success(val name: String) : ProfileResult()
data class Failure(val message: String) : ProfileResult()
}
class ProfileService {
fun loadProfile(available: Boolean): ProfileResult {
return if (available) {
ProfileResult.Success("Alex")
} else {
ProfileResult.Failure("Profile is unavailable")
}
}
}
This example illustrates an explicit result contract, but sealed-class export may not be especially convenient in every Swift binding. The generated representation should be checked, and an iOS-friendly wrapper may be appropriate.
For APIs that must integrate naturally with Swift error handling, design and test the exported signature deliberately. Where supported by the selected Kotlin export mechanism, use its documented error-mapping features rather than relying on undocumented behavior.
Do not expose internal exception messages directly to end users. Log diagnostic details separately and return messages that are safe for the calling application to display.
Problem 4: Calling Kotlin Coroutines From Swift
Coroutines are a common source of confusion when Kotlin Multiplatform code is called from iOS.
A shared service might use a suspending function to retrieve data:
class UserRepository {
suspend fun loadUserName(): String {
// Fetch data from a repository or remote service.
return "Alex"
}
}
A suspend function is not an ordinary synchronous function. It may pause without blocking the thread while waiting for an operation to complete.
However, Swift cannot necessarily call a Kotlin suspending function using native Swift async syntax just because the function is marked suspend. The behavior depends on the Kotlin version and the interoperability mechanism used by the project.
Choose a Concurrency Integration Strategy
There are several ways to expose asynchronous shared code:
Use a documented Swift interoperability feature supported by the project's Kotlin version.
Provide a callback-based API where appropriate.
Use an established interoperability library that bridges Kotlin coroutines and Swift concurrency.
Introduce an iOS-specific adapter that converts the shared API into a Swift-friendly interface.
The right choice depends on the project's dependencies, supported OS versions, and concurrency requirements.
Avoid creating a new coroutine scope for every request without considering its lifecycle. Unmanaged scopes can continue work after a screen disappears, produce duplicate requests, or make cancellation difficult.
Shared services should have clear ownership and cancellation behavior. The iOS layer should also avoid blocking the main thread while waiting for a network request or other long-running operation.
Problem 5: Kotlin Flow and Swift Observation Are Different
Kotlin Flow provides a way to represent asynchronous streams of values. Swift commonly uses mechanisms such as AsyncSequence, Combine publishers, or callbacks to observe changing data.
A shared Kotlin repository might expose a flow:
class SessionRepository {
private val sessions = MutableStateFlow("Signed out")
val sessionStatus: StateFlow<String> = sessions.asStateFlow()
fun signIn() {
sessions.value = "Signed in"
}
}
The Kotlin API can be consumed naturally by Kotlin callers, but Swift does not automatically gain a native AsyncSequence or Combine publisher from every Flow declaration.
A bridging strategy is required. Some projects use interoperability libraries, while others expose callbacks or platform-specific adapters.
For example, a callback-oriented API could provide a simpler boundary:
class SessionStatusService {
fun currentStatus(): String {
return "Signed out"
}
}
This example only returns a snapshot; it does not replace the ongoing observation that a Flow provides. For a real stream, the API must also define how updates are delivered, how subscriptions are cancelled, and how observers are released.
When designing a stream bridge, pay attention to:
Subscription lifecycle.
Cancellation when a screen or owner is removed.
Threading and UI updates.
Error delivery.
Whether the stream replays the latest value.
Avoiding duplicate observers.
A bridge that delivers values correctly but never cancels subscriptions can still cause memory and performance problems.
Problem 6: Framework Integration and Build Configuration
Sometimes the Kotlin code itself is correct, but Swift cannot import the shared framework or recognize a newly added declaration.
Common causes include:
The framework was not built for the selected target.
The Xcode project references an outdated framework.
The Kotlin source is in the wrong source set.
The declaration is not exported through the configured framework.
The framework's supported architectures do not match the build target.
The build system is using stale generated output.
Start by confirming that the shared module builds successfully for the intended iOS target.
Then verify that the framework is linked to the Xcode target and that the Swift file imports the correct framework name.
If you recently changed an exported Kotlin declaration, rebuild the framework and refresh the generated integration artifacts as required by the project setup.
For projects using Kotlin Multiplatform with direct Xcode integration or a tool such as CocoaPods, follow the build process configured for that project. Mixing integration approaches without a clear reason can create duplicate or inconsistent framework outputs.
Best Practices for Kotlin and Swift Interoperability
A reliable integration depends on API design as much as build configuration.
Keep shared APIs focused on business logic rather than platform-specific UI behavior.
Define nullability carefully and avoid ambiguous return values.
Use simple data models at the language boundary.
Make error handling explicit for operations that can fail.
Choose a documented strategy for bridging coroutines and Flow.
Define cancellation and subscription ownership for asynchronous operations.
Inspect generated framework interfaces when a Swift signature looks unexpected.
Test the shared module and the iOS integration independently.
Rebuild framework artifacts after changes to exported declarations.
Include interoperability checks in the iOS build pipeline.
It is also useful to create a small integration test that imports the framework from Swift and exercises the most important shared APIs. Such a test can detect problems that a successful Kotlin compilation alone cannot catch.
Advantages and Limitations
Kotlin Multiplatform reduces duplicated business logic across Android and iOS. Shared validation, networking, and data-access code can behave consistently, while Swift remains responsible for native iOS presentation.
The trade-off is that the language boundary introduces additional considerations. Kotlin types do not always map directly to the types Swift developers expect, asynchronous APIs need a deliberate bridge, and generated framework interfaces can change as the project evolves.
These issues are manageable when the shared module exposes a small, stable API and the iOS application tests that API as part of its normal build process.
Summary
Calling Kotlin Multiplatform code from Swift involves more than importing a framework. Developers must account for differences in type mapping, naming, error handling, asynchronous execution, and build integration.
The most reliable approach is to design shared APIs with both languages in mind, inspect the generated interface, and select a documented strategy for coroutines and Flow. Clear contracts and focused integration tests help catch interoperability issues before they reach production.
With a well-defined boundary between shared business logic and native iOS code, teams can reuse Kotlin effectively without making the Swift application difficult to maintain.
Join the conversation! Your thoughts help the community grow.