Skip to content

Replace SKIE with Kotlin's Swift export - #8

Open
solcott wants to merge 8 commits into
feature/iosAppfrom
spike/swift-export
Open

Replace SKIE with Kotlin's Swift export#8
solcott wants to merge 8 commits into
feature/iosAppfrom
spike/swift-export

Conversation

@solcott

@solcott solcott commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Stack 4/4 — base feature/iosApp (#7).

An experiment: can JetBrains' own Swift export replace SKIE? It can, on all three Apple
destinations. This documents what that costs.

The docs are out of date

Two limitations the Swift export page still
lists as current are gone in Kotlin 2.4.20-Beta2:

Documented limitation Still true?
Cross-language inheritance unsupported No. 2.4.10 emitted a literal fatalError("Inheritance from exported Kotlin classes is not supported yet") in every initialiser; on Beta2 there are zero occurrences
Sealed types lose exhaustiveness No. sealedType() returns a real Swift enum with all cases
Generic parameters type-erased Yes
List/Set/Map inheritors (KT-80416/7) Yes — irrelevant here
Opt-in needs a module-level flag Yes — never triggered
Direct integration only Yes — already how this builds

macOS also works, which the docs never mention: diff -r between the macOS and iOS generated
Swift is empty, and both link end to end. Every official example and JetBrains' own sample is iOS-only,
so this was the stop condition for the spike.

Four undocumented behaviours

Written up under "Swift export bugs worth reporting" in AGENTS.md. A YouTrack search found no existing
report for the first two, though that search was shallow enough to be "not found" rather than "absent":

  1. A generic sealed interface generates Swift that does not compile — the erased subtype is not
    made to conform to the erased parent protocol.
  2. Sealed interface members are unreachable across modules — the nested DataError.Network
    convenience names are emitted as typealiases with no access modifier, so only the mangled
    top-level class is public.
  3. Saver.save generates a malformed reverse-interop thunk — it takes an extension receiver, and
    the generated code passes the receiver as the value: argument. This is what makes any Compose
    type in the exported API fatal.
  4. The generated coroutine support requires iOS 18 (Synchronization.Mutex), with no documented
    minimum OS.

Both (1) and (2) turn out to be sealed-interface problems: a sealed class exports its members as
genuinely nested public classes and survives erasure by plain subclassing. Outcome, DataError and
LoadStatus are sealed classes for that reason.

The module split

:model had drifted — alongside Country and Continent it held DataError, Origin and Outcome,
which describe not a thing in the domain but how a read of it went. Those move to :dataresult;
ContentState and LoadStatus move to :uistate. That is the right layering on its own, and it
is also exactly the line Swift export needed: export(project(…)) exports a module's API in full, so
it is only usable where nothing breaks the generator.

Net effect: KotlinTypes.swift is deleted — all 12 typealiases gone.

What it costs

  • Kotlin 2.4.20-Beta2, a beta compiler. SKIE caps at 2.4.10, so the two cannot coexist.
  • The iOS floor goes 17 → 18.
  • sealed interfacesealed class on three shared types, which is the less idiomatic Kotlin.

Verification

ktfmtCheck, test, assembleDebug, :apple:allTests, :desktop and :web compiles; iPhone and
iPad both green (7 UI + 8 unit tests each); macOS builds.

The XCUITests were never edited. They contain no Kotlin, so a 13-module reshuffle and a full interop
replacement passing them unchanged is the real evidence behaviour is identical.

🤖 Generated with Claude Code

solcott and others added 8 commits August 11, 2026 16:49
Probe only. SKIE is removed from :apple and a swiftExport block added, but the
XCFramework binaries stay exactly as they were, so the Obj-C framework iosApp/
links is untouched and nothing in Swift has been asked to change yet.

Deliberately still on Kotlin 2.4.10. Swift export has run on stable Kotlin since
2.2.20, so "does swift export cope with this API surface" and "does this repo
survive a Kotlin beta" are separable questions; fusing them would make a failure
uninterpretable.

Findings, from apple/build/SwiftExport/<target>/Debug/files:

  - macOS works. `diff -r` between the macosArm64 and iosSimulatorArm64 output is
    empty — byte-identical Swift. That was the open question the docs never answer:
    they never mention macOS, and every JetBrains example and their own sample
    declares iOS targets only. It is undocumented, not unsupported.
  - ContentState survives but is fully erased: `public final class ContentState`
    with no type parameter, and `data: (any KotlinRuntimeSupport._KotlinBridgeable)?`.
    Obj-C at least bridged it far enough for `as? [Country]` to work.
  - Concrete List<T> in a signature is fine — `selectedContinents` comes through as
    a real `Swift.Array<Continent>`. The erasure is a generic-class problem, not a
    collection problem.
  - eventSink survives as an honest Swift closure taking `any ...Event`.
  - The Kotlin extension properties invisible to Obj-C — isLoading, errorOrNull —
    are exported as free functions taking the receiver. LoadPhase.swift exists
    partly to reimplement those.
  - TextFieldState exports rather than erroring, dragging Compose UI text, graphics,
    geometry and unit along: ~8k lines of generated Swift for a property Swift
    cannot use.
  - DataError and LoadStatus are bare empty protocols with no cases, and the
    generated code carries an explicit `fatalError("Inheritance from exported Kotlin
    classes is not supported yet")`. Both are the 2.4.10 behaviour and both are what
    the 2.4.20-Beta2 bump is meant to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ssing

The bump on its own, so that if it has to be reverted the Swift export work
survives. This is the change SKIE cannot coexist with: SKIE 0.10.14 supports
2.0.0 through 2.4.10 only, which is why it came out in the previous commit
rather than this one.

Both of the limitations the docs still list as current are gone here, confirmed
by regenerating the export and diffing against the 2.4.10 output:

  - Sealed types now map to real Swift enums. DataError gets a generated
    `sealedType()` returning `DataError_SealedType` with all five cases
    (api/http/network/serialization/unknown) and a `.value` accessor; LoadStatus
    likewise. That is a like-for-like replacement for SKIE's `onEnum(of:)`,
    including the exhaustiveness that made it worth having.
  - Cross-language inheritance is supported. The generated code on 2.4.10 carried
    an explicit `fatalError("Inheritance from exported Kotlin classes is not
    supported yet")` in every class initialiser; on Beta2 there are zero
    occurrences.

Still erased, as the docs say: ContentState exports without its type parameter
and `data` is `(any KotlinRuntimeSupport._KotlinBridgeable)?`. Not the blocker it
first looks like — `__createBridgeable` unboxes a Kotlin List to `[Any]`
(KotlinRuntimeSupport case 13) and Swift downcasts arrays element-wise, so the
`data as? [Country]` the app already performs under Obj-C keeps working. Same
ergonomics as today, so no DTO facade is needed.

Verified on the beta: :build-logic:build, :presenter:compileKotlinIosSimulatorArm64
(Metro, redacted and Compose together — the real risk, since Metro backs nine
modules), 🍎allTests on both simulator and macOS, and `test assembleDebug`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running the real pipeline (:apple:embedSwiftExportForXcode, which generates the
Swift, builds a synthetic SPM package with xcodebuild, and copies the static
library and .swiftmodules into BUILT_PRODUCTS_DIR) turns up three things the
docs do not mention.

1. Swift export's coroutine support requires iOS 18 / macOS 15. The generated
   KotlinCoroutineSupport.swift uses Synchronization.Mutex:

     error: 'Mutex' is only available in iOS 18.0 or newer

   This app's floor is iOS 17.0, so the app cannot adopt Swift export's flow
   support without raising it. macOS is already at 15.0 and unaffected. Nothing
   in the Swift export documentation states a minimum OS.

2. `export(project(...))` is a trap here. It does not mean what it means on an
   Obj-C framework: Swift export already emits everything reachable from this
   module's public API, so :model and :presenter come through either way. Naming
   them exports their public API *in full*, which pulled in `Outcome<out T>` — a
   generic sealed interface no Swift code touches — and 2.4.20-Beta2's new
   sealed-enum codegen emits invalid Swift for it. Dropping the explicit exports
   drops `Outcome` and fixes it. So the feature that makes the beta worth having
   is also the one that breaks on a generic sealed type.

3. What remains is TextFieldState, and only TextFieldState. Compose's
   `Saver.save` is a method with an extension receiver; the reverse-interop thunk
   generated for it passes the receiver as the `value:` argument and omits the
   receiver entirely, so the generated Swift does not compile. Confirmed by
   probe: with `CountryListScreen.State.nameStartsWithText` temporarily retyped
   to String, the Compose error disappears and the whole Compose UI tree
   (text/graphics/geometry/unit, ~8k lines of generated Swift) stops being
   exported. That probe was reverted; :presenter is untouched.

Also confirmed here, and better than the plan assumed: `StateFlow` is *not*
erased. It exports as `any KotlinTypedStateFlow<CountryListScreen.State>` with a
typed non-optional `value` and `asAsyncSequence()` — a direct replacement for
SkieSwiftStateFlow, so no Kotlin-side currentState/states split is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last blocker. CountryListScreen.State carries a Compose TextFieldState, and
Swift export generates invalid Swift for Compose's Saver — its `save` takes an
extension receiver and the reverse-interop thunk drops it, passing the receiver
as `value:`. That is unfixable from here, so the Circuit state stops crossing the
boundary.

:apple now publishes CountryListUiState and CountryDetailUiState: no generics, no
Compose, no Circuit. What still crosses is :model's data classes and LoadStatus,
which is a plain non-generic sealed interface and so exactly the shape
2.4.20-Beta2's sealed-enum support handles well.

eventSink is kept `internal` and replaced on the public API by named methods —
search/toggleContinent/retry, back/retry. Partly ergonomics, but mainly safety:
CountryListScreen.Event is a sibling nested type of CountryListScreen.State, and
exporting one risks dragging the other, and TextFieldState with it, straight back
in.

This also answers the ContentState<T> question by making it moot. Nothing generic
crosses now, so the erased `data` and the unchecked `as? [Country]` the Obj-C
build needed both disappear; countries and continents arrive as typed Swift
arrays.

Effect on the export surface: Compose is gone from it entirely. The generated
Swift no longer contains ui-text, ui-graphics, ui-geometry, ui-unit or
foundation — about 8,000 lines that existed only to describe a property Swift
could never use.

Both slices now build end to end through 🍎embedSwiftExportForXcode,
including macOS, which was the stop condition for this spike.
🍎allTests passes; PresenterHolderTest reads the facade's own fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Xcode now runs 🍎embedSwiftExportForXcode. There is no framework to embed
or sign, so FRAMEWORK_SEARCH_PATHS is gone and OTHER_LDFLAGS loses
`-framework CountriesKit`, gaining `-lsqlite3` in its place — the static library
records no linker options, so SQLiter's symbols resolve at the app link.

IPHONEOS_DEPLOYMENT_TARGET goes 17.0 -> 18.0. That is a real cost, not a tidy-up:
Swift export's generated coroutine support uses Synchronization.Mutex, so
adopting its Flow support means dropping iOS 17. macOS was already at 15.0.

The Swift port itself is small, because Swift export turned out to cover both
things SKIE was here for:

  - `StateFlow` reaches Swift as `KotlinTypedStateFlow<T>`, with a typed
    non-optional `value` and `asAsyncSequence()`. `PresenterHolding` survives
    almost verbatim; only the flow type and a `try` on the iteration changed.
  - `onEnum(of:)` becomes `sealedType()`, and both switches in LoadPhase.swift
    still have no `default:` case.

Navigation moved behind the facade too. SwiftNavigator now takes and returns
country codes rather than Circuit Screens, and builds the two Screens itself.
Swift no longer names Screen, Navigator, CountryListScreen or
CountryDetailScreen at all.

Two things are worse than under SKIE, both recorded in KotlinTypes.swift:

  - Nothing is flattened. `flattenPackage` only applies inside an
    `export(project(…))`, which is exactly what cannot be used here, so the app
    declares its own typealiases onto ExportedKotlinPackages.
  - Sealed members are worse still: Swift export generates the nested
    `DataError.Network` convenience names without an access modifier, so they are
    internal to the generated module and unreachable. Only the mangled top-level
    class is public. Flattening those by hand reproduces exactly the names Obj-C
    interop used to mangle for free. Construction only — reading goes through
    `sealedType()` and needs none of it.

macOS needed one extra linker flag. Apple's system libsqlite3 omits
sqlite3_load_extension and sqlite3_enable_load_extension, which SQLiter
references and never calls; the old dynamic framework never had to resolve them,
a static library linked into the app does. Allowed as undefined by name rather
than by loosening the whole link.

Verified: iPhone 17 Pro and iPad mini 18.4 both green — 7 XCUITests and 8 Swift
Testing tests each — plus a macOS build, 🍎allTests, and ktfmtCheck. The
XCUITests were not edited, which is the point: they contain no Kotlin, so they
only pass if the behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing applies the plugin now. The entry stays so reverting the spike is a
two-line change, but its old comment described behaviour that is no longer
wired up, and it did not say the thing that now matters most: SKIE caps at
Kotlin 2.4.10, so going back to it means giving up 2.4.20-Beta2 and with it
the sealed-enum and cross-language-inheritance support this spike was for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ft shim

Two changes that turn out to be the same change.

**The layering.** `:model` was meant for domain nouns and had drifted: alongside
Country and Continent it held DataError, Origin and Outcome, which describe not a
thing in the domain but how a read of it went. Those move to `:dataresult`, at the
bottom of the graph with no dependencies. ContentState and LoadStatus move out of
`:presenter` into `:uistate`, which depends only on `:dataresult`. `:model` is now
Country.kt and Continent.kt and nothing else.

`applyEmission` stays behind in `:presenter` as ApplyEmission.kt, next to its only
two callers. It is the one member of ContentState.kt that touches Outcome, and
leaving it out is what keeps `:uistate` free of any dependency but `:dataresult`.

**The export.** That split is exactly the line Swift export needed. `export(project(…))`
is the only way to set `flattenPackage`, but it exports a module's public API *in
full*, so it is only usable on a module where nothing breaks the generator. Three
such modules now exist, all three are exported and flattened, and `:presenter` —
which still carries TextFieldState — is not. Nothing reaches into it anyway; the
facade in AppleUiState.kt already saw to that.

Outcome, DataError and LoadStatus become sealed **classes**. Both Swift export
problems turned out to be sealed-*interface* problems, verified against the
generated Swift:

  - A generic sealed interface emits invalid Swift: the erased `Outcome_Data` does
    not conform to the erased `Outcome` protocol, and there is no way for it to.
    As a sealed class it is plain subclassing, which survives erasure —
    `public final class Data: …Outcome`.
  - Sealed interface members reach Swift as a mangled top-level class plus a nested
    typealias emitted with **no access modifier**, so they are internal to the
    generated module and unreachable from the app. Sealed class members are
    genuinely nested public classes: `DataError.Network` is just there.

`sealedType()` and its exhaustive switch survive both conversions, which is the
property worth protecting. The cost is that `sealed interface` is the more
idiomatic Kotlin for a stateless hierarchy, and `any DataError` becomes plain
`DataError` in Swift now that it is a class rather than a protocol.

Net effect: KotlinTypes.swift is **deleted** — all 12 typealiases gone, five to
flattening and seven to the sealed-class conversion.

Verified: ktfmtCheck, test, assembleDebug, 🍎allTests, :desktop and :web
compiles, and an iPhone build of the SwiftUI app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTS.md's module list goes 11 -> 13 and states the rule the split encodes:
`model` is domain nouns, `dataresult` is how a read went, `uistate` is view state.
It also says the part that is not obvious from the code — those three modules are
exported to Swift *in full*, so adding a Compose type or a generic sealed type to
any of them breaks the iOS build with nothing to warn you.

The `apple` section is rewritten off SKIE. The rule worth having in writing is
that a sealed type crossing to Swift must be a `sealed class`: a sealed
*interface* member reaches Swift only through a typealias emitted with no access
modifier, and a *generic* sealed interface does not compile at all. Also recorded:
that `export()` here exists to set `flattenPackage` rather than to make types
visible, that the iOS floor is 18 because the generated coroutine support uses
Mutex, and where `-lsqlite3` went.

Added a "Swift export bugs worth reporting" list with the four undocumented
behaviours found while porting. A YouTrack search turned up no existing report for
the first two, though the search was shallow enough that this is "not found"
rather than "does not exist".

Replaced the stale assembleCountriesKitDebugXCFramework command with the one that
is actually useful now: generating the Swift without going through Xcode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant