The Dark Corners of Kotlin Multiplatform, Part 2 | by Andrea Della Porta | Jul, 2026

The deepest corner isn’t technical — plus the tools I wouldn’t name last time, and the interop finally shown in code. 🌗

Press enter or click to view image in full size

Back in March I made one argument: Kotlin Multiplatform is not a code-sharing technology. It’s a boundary-management technology. Everything depends on what survives the crossing.

I still believe every word of it. But I wrote that piece under two deliberate constraints.

First, I kept it abstract — no versions, no tool names, barely a line of code — so it would still be true two years later. Second, I kept it narrow. It never left the interop boundary: API shape, sealed hierarchies, default parameters, error handling, and the general idea that better tooling helps but isn’t magic. I never touched build times, framework distribution, UI testing, or the memory model. That was on purpose too — one article, one idea.

A few months on, I’ve shipped more KMP and given the talk again — this time in Berlin. So this is the sequel that first piece set up: same thesis, but specific.

I’ll name the tools I refused to name. I’ll revisit the corners I described in prose and actually show you the code. I’ll go past the boundary, into the corners Part 1 never reached. And I’ll tell you about one corner that closed completely — and one that was never about code at all.

Because here’s the thing nobody tells you about dark corners: they change shape. Let me get specific.

The interop boundary, this time in code

Part 1 described “translation loss” entirely in prose. I said modern Kotlin features “stop feeling native” when they cross to Swift, and left it there. A few months of people asking me “okay, but show me” have convinced me the code makes the point better than the abstraction did.

Sealed hierarchies

In Kotlin, a Result type is exhaustive, safe, idiomatic — and the compiler has your back:

// Kotlin — exhaustive, safe, idiomatic
sealed class LoadResult {
data class Success(val users: List) : LoadResult()
data class Error(val cause: Throwable) : LoadResult()
data object Loading : LoadResult()
}
when (result) {
is LoadResult.Success -> render(result.users)
is LoadResult.Error -> show(result.cause)
LoadResult.Loading -> spinner()
} // add a 4th case → this stops compiling until you handle it

Cross the traditional Objective-C bridge and that hierarchy becomes a base class with subclasses. No native exhaustive switch. No compiler help:

// Swift, through the Objective-C bridge — no exhaustiveness
if let success = result as? LoadResultSuccess {
render(success.users)
} else if let error = result as? LoadResultError {
show(error.cause)
} else if result is LoadResultLoading {
spinner()
}
// add that 4th case in Kotlin → this STILL compiles.
// the new state just falls through. silently.

That’s where it actually bites. Add a fourth case — say, Offline. On Android the compiler instantly flags every when that no longer compiles. On the Swift side, nothing complains. Your compiler-enforced invariant on one platform is a runtime surprise on the other. This is exactly what I meant in Part 1 by “safe by design” degrading into “safe if everyone remembers” — I just wouldn’t show it to you last time.

Generics

A Kotlin function returning List often degrades into an NSArray — a collection of “something.” A generic fetch collapses to returning Any. Objective-C only supports generics on classes and collections; everything else gets erased. Your iOS colleague is back to casting and hoping.

Default parameters

This is the one corner Part 1 did show in code — the “smallest paper cut that never stops cutting.” Here it is again, with the sting I left out:

// Kotlin: clean, compact, and non-breaking to extend
fun loadUsers(id: String, timeout: Int = 5000, useCache: Boolean = true)

Through the classic bridge, those defaults simply aren’t preserved — every parameter becomes required. And here’s the part I underplayed as “cumulative, not theatrical”: adding a defaulted parameter later is a non-breaking change in Kotlin. On the Swift side, it breaks every call site. Repeated across a shared module, that’s not a paper cut anymore. It’s the thing that turns into “why is this like this?” in your team’s Slack.

Error handling

In Part 1 I wrote that “your error model is only as good as the platform consuming it.” Here’s what that sentence looks like in practice. You throw a typed exception in Kotlin:

// Kotlin — typed, intentional, part of a hierarchy you designed with care
@Throws(NetworkException::class)
suspend fun loadUsers(): List

On the Apple side, that clean structure arrives as an NSError, with your actual exception buried inside userInfo:

// Swift — the archaeology
do {
let users = try await loader.loadUsers()
} catch let error as NSError {
let kotlinException = error.userInfo["KotlinException"]
// now cast it. and pray it's the type you expected.
}

And there’s a subtler trap: only exceptions you annotate with @Throws are bridged as catchable at all. Anything else that crosses the boundary uncaught doesn’t become a Swift error you can handle — it terminates the app.

The workaround is the same design discipline I recommended last time, now stated concretely: don’t throw across the boundary unless you truly have to. Return a sealed ResultSuccess carries the data, Failure carries a typed domain error — so the exported API is explicit instead of something the iOS side has to excavate.

“Better tooling isn’t magic” — a few months on, with names

In Part 1 I wrote that better tooling improves the crossing but never eliminates the boundary — and I pointedly refused to name a single tool, to keep the piece evergreen. A few months on, the specifics are more useful than the caution, so let me name them.

SKIE, from Touchlab, is the highest-impact one for most teams. It turns that sealed hierarchy back into a real Swift enum, so exhaustiveness comes home:

// Swift, with SKIE — a real enum, exhaustive again
switch onEnum(of: result) {
case .success(let s): render(s.users)
case .error(let e): show(e.cause)
case .loading: spinner()
}

It also bridges Flows properly and makes async usable from Swift. I use it. Evaluate it early. But it’s exactly what I warned about abstractly last time: a third-party dependency with its own version-alignment story with Kotlin and Gradle. Better leverage — not a free lunch.

And then there’s Swift Export — JetBrains’ official answer that eventually removes the Objective-C bridge from the equation entirely. Let me be precise about its status, because this is the single most misquoted thing in the whole KMP conversation: I keep hearing it called “beta.” It isn’t.

It was Experimental in Kotlin 2.2.20 — and the milestone in that release wasn’t a launch, it was simply that it became available by default, without the kotlin.experimental.swift-export.enabled Gradle flag. Since then it has moved up to Alpha. And note the date: we’re on Kotlin 2.4 now, and it’s still Alpha — the docs are blunt that it’s “still incomplete, so breaking changes are expected.” The roadmap does not promise a stable release in a specific version, on a specific date.

So the honest framing is the one Part 1 was built on: the direction is real, the bridge is on its way out, but this is improved support for your design effort — not a replacement for it. Watch it closely. Don’t bet a production migration on it yet.

The state of the world in 2026 is better than it used to be. It’s just not boring yet.

Past the boundary: the corners Part 1 never reached

Everything above was still the interop boundary — the only thing the first article talked about. But shipping KMP has dark corners that have nothing to do with API shape, and I left all of them out last time. Here they are.

Build times and Xcode tooling

Android builds are fast enough that you stay in flow. iOS Simulator on Apple Silicon is manageable. Device builds are heavier. And iosX64, for Intel Mac simulators, if you still support them — go make a whole pot of coffee. Kotlin/Native has improved, but cold-build performance is not where JVM developers emotionally expect it to be, and once your shared module stops being toy-sized, build times become part of the architecture discussion.

Xcode itself is better than it used to be, but still not first-class. You can debug Kotlin from Xcode now — Touchlab’s xcode-kotlin plugin lets you set breakpoints in Kotlin source — but it’s an unofficial LLDB injection you re-sync on every Xcode update, with no expression evaluation and no seamless stepping from Swift into Kotlin. And when Kotlin fails to compile, the real error lands in the Gradle log while Xcode just reports that a run-script phase failed. Gradle screams; Xcode shrugs.

The corner I never mentioned: shipping the framework

Here’s the omission I got called out on most. Even once your shared code compiles, you still have to get it into the iOS app — and that’s a whole task of its own.

The shared module compiles to an XCFramework, and someone has to wire it into the Xcode project. The moment your iOS app lives in its own repo, you’re into distribution: CocoaPods was the historical default, the ecosystem is migrating to Swift Package Manager, and you have to version and publish that framework somewhere the iOS team can consume it. This is painful enough that Touchlab built an entire tool — KMMBridge — to automate it, and JetBrains has a next-gen distribution format on the roadmap. “How does the framework reach the iOS app” is a day-one question that never shows up in the getting-started guide.

Compose on iOS: genuinely better, still one surface

If you share UI with Compose Multiplatform, there’s a corner here too — and this is the one where 2026 is meaningfully better than the early KMP days. Compose declared iOS stable in 1.8.0, in May 2025, with first-class accessibility: VoiceOver, Voice Control, Full Keyboard Access. We’re on the 1.11 line now, and there’s a new generation of Compose UI test APIs for non-Android targets.

But the structural catch hasn’t moved: Compose renders into a single surface, not a native view hierarchy. To XCTest and Apple’s native accessibility tooling, a screen can still look like one opaque view. “Stable” and “fully inspectable by XCTest” are not the same sentence. Set semantics properties deliberately, test shared UI on iOS early, and verify what your tooling can actually see in your real app — not in the docs.

The corner that closed

I said dark corners change shape. Here’s the best proof, and it’s pure good news.

A few years ago the darkest corner in all of Kotlin/Native was the memory model. Object freezing, @SharedImmutable, freeze(), the infamous InvalidMutabilityException when you touched shared state from the wrong thread. It’s gone. The new memory manager became the default in Kotlin 1.7.20 (2022), and the legacy freezing model was fully removed in 1.9.20. Today Kotlin/Native works the way a JVM developer expects — shared heap, no freezing, no ceremony — and most people who started KMP recently don’t know it was ever a problem.

So the boundary is permanent, but the individual corners are not. Today’s dark corner can be next year’s footnote.

The deepest dark corner isn’t between two languages

Everything above is technical. This isn’t, and it’s the most important thing I’ll write in either article.

Part 1 hinted at it — “technically yes, but emotionally no,” the shared module that another team can only use with “a field guide, a support ticket, and a therapy session.” Let me say it plainly now.

Look at who feels all of it. The interop friction. The archaeology in userInfo. The build times. The framework-shipping ceremony. It all lands on the same person: your iOS colleague. The one who didn’t ask for Gradle in their life.

The deepest dark corner in Kotlin Multiplatform isn’t in the compiler. It’s the gap between the Android team that chose KMP and the iOS team that has to live with it. Every technical corner in this article terminates on that relationship. If the iOS team believes KMP is why their day got slower, you’ve lost them — even when the real fix is a caching config, not a retreat.

Get that relationship right, and the rest is just engineering.

Where I draw the line now

Part 1 ended on a mindset: treat KMP as “an API product that happens to be written in Kotlin.” A few months later, here’s the line that mindset drew for me — and it’s not all-or-nothing.

Shared business logic — networking, data, domain rules, the stuff with no reason to differ between platforms — almost always worth sharing. That’s where KMP pays for itself immediately, and most of the corners above are manageable there.

Shared UI with Compose — a “sometimes.” Real and viable in 2026, not a joke anymore. But a decision you make screen by screen, weighing testing and accessibility, not a switch you flip for the whole app.

The mistake I keep seeing is teams treating “multiplatform” as all-or-nothing. The ones who succeed pick the layer, share aggressively there, and stay honest about the layers above.

The real lesson, a few months on

In Part 1 I said the boundary is where the hardest trade-offs live, and that KMP is a system for negotiating platform differences more deliberately. That’s still the whole thesis. What changed is the detail underneath it: some corners closed, the tooling I wouldn’t name got sharper, Swift Export inched from Experimental to Alpha, Compose on iOS grew up. Adoption roughly doubled in a year — from about 7% of surveyed developers in 2024 to around 18% in 2025 — so the ecosystem is now defined by production experience, not tutorials.

And yet the discipline is unchanged: design your exported surfaces, not just your Kotlin. Test consumption on the Apple side early. Treat the framework’s journey to iOS as a day-one problem. And bring your iOS team with you.

So — would I use KMP in production again? Yes. Absolutely yes. Not because the dark corners are gone, but because I know which ones closed, which ones are still dark, and which one was never about code at all.

Kotlin Multiplatform still doesn’t get interesting when the demo works. It gets interesting when the translation starts. A few months on, I just know the translation a lot better.

Some corners are getting lighter. Some are still very dark. Eyes open.

Go build something great.

Similar Posts

Leave a Reply