Why it is shaped this way
Two runtimes, one compiler
Each runtime is written in its platform's language and feels like it. What the platforms share is decided before the app runs: the compiler, the plan it emits, the vocabulary, and the fixtures both runtimes must pass. Nothing is shared through a bridge.
Each runtime is written in its platform's language and feels like it. What the platforms share is decided before the app runs: the compiler, the plan it emits, the vocabulary, and the fixtures both runtimes must pass. Nothing is shared through a bridge.
Why#
A compiler written in Rust makes a shared Rust runtime look natural: one store, one ingest, one persistence layer, thin bindings. The design forbids it at its heart. A view body reads a field as a synchronous slot load that registers with Observation; a Rust record behind a foreign-function call would hand back a string to copy on every render, or be mirrored by a native cache that is a second store. The registrar the UI framework tracks is a native object per record in any case. Whatever walks records (commits, garbage collection, availability checks, optimistic rebase, connection merging) lives where the records live. The large native apps that run a shared C++ core pay for it with materialized native copies of their data, which is the model this library exists to avoid.
The opposite failure is two runtimes that drift: a response that normalizes one way on iOS and another on Android, a connection that merges differently, an error that survives caching on one platform only.
The idea#
CharactersScreen.baton.swift
// Generated by batonc. Do not edit.
import Foundation
@_spi(Generated) import Baton
/// Lens for `fragment CharacterRow_character on Character`.
nonisolated public struct CharacterRow_character: Baton.Lens {
@_spi(Generated) public let anchor: Baton.Anchor
@_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
@MainActor public var name: String? { anchor.string(Slots.Character.name) }
@MainActor public var status: String? { anchor.string(Slots.Character.status) }
@MainActor public var species: String? { anchor.string(Slots.Character.species) }
@MainActor public var image: String? { anchor.string(Slots.Character.image) }
}
/// Operation value for `query CharactersQuery`.
nonisolated public struct CharactersQuery: Baton.Query {
public var page: Int?
@_spi(Generated) public var resolution: Baton.Resolution<Baton.OperationHandle<Self>> = .unresolved
public init(page: Int? = nil) {
self.page = page
}
public static let name = "CharactersQuery"
public static let document: Baton.Document = .text(#"query CharactersQuery($page:Int){characters(page:$page){results{id,...CharacterRow_character}}}fragment CharacterRow_character on Character{name,status,species,image}"#)
public var variables: Baton.Variables {
var values: [String: Baton.Variable] = [:]
if self.page != nil { values["page"] = Baton.Variable(self.page) }
return Baton.Variables(values)
}
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.page == rhs.page
}
public func hash(into hasher: inout Hasher) {
hasher.combine(self.page)
}
@_spi(Generated) public static let plan = Baton.Plan(root: selection0)
private static let selection0: Baton.Selection = Baton.Selection(type: Types.Query, key: [], abstract: false, fields: [
.linked("characters", key: .dynamic(Slots.Query.characters_5517f9), plural: false, selection: selection1),
])
private static let selection1: Baton.Selection = Baton.Selection(type: Types.Characters, key: [], abstract: false, fields: [
.linked("results", key: .fixed(Slots.Characters.results), plural: true, selection: selection2),
])
private static let selection2: Baton.Selection = Baton.Selection(type: Types.Character, key: ["id"], abstract: false, fields: [
.scalar("id", key: .fixed(Slots.Character.id), kind: .string, list: false),
.scalar("name", key: .fixed(Slots.Character.name), kind: .string, list: false),
.scalar("status", key: .fixed(Slots.Character.status), kind: .string, list: false),
.scalar("species", key: .fixed(Slots.Character.species), kind: .string, list: false),
.scalar("image", key: .fixed(Slots.Character.image), kind: .string, list: false),
])
nonisolated public struct Data: Baton.Lens {
@_spi(Generated) public let anchor: Baton.Anchor
@_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
@MainActor public var characters: Characters? { anchor.linked(anchor.owner.slot(Slots.Query.characters_5517f9)).map(Characters.init(anchor:)) }
nonisolated public struct Characters: Baton.Lens {
@_spi(Generated) public let anchor: Baton.Anchor
@_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
@MainActor public var results: Baton.List<Results>? { anchor.list(Slots.Characters.results) }
nonisolated public struct Results: Baton.Lens {
@_spi(Generated) public let anchor: Baton.Anchor
@_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
@MainActor public var id: String? { anchor.string(Slots.Character.id) }
@MainActor public var characterRow: CharacterRow_character { .init(anchor: anchor.entering()) }
}
}
}
}CharactersScreen.baton.kt
// Generated by batonc. Do not edit.
@file:OptIn(baton.Generated::class)
package app
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import baton.Anchor
import baton.Document
import baton.Lens
import baton.OperationKind
import baton.OperationType
import baton.Plan
import baton.PlanField
import baton.QueryOperation
import baton.QueryType
import baton.ScalarKind
import baton.Selection
import baton.StorageKey
import baton.Variable
import baton.Variables
/** Lens for `fragment CharacterRow_character on Character`. */
@Stable
class CharacterRow_character(override val anchor: Anchor) : Lens {
val name: String? get() = anchor.string(Slots.Character.name)
val status: String? get() = anchor.string(Slots.Character.status)
val species: String? get() = anchor.string(Slots.Character.species)
val image: String? get() = anchor.string(Slots.Character.image)
override fun equals(other: Any?): Boolean = other is CharacterRow_character && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
}
/** Operation value for `query CharactersQuery`. */
@Immutable
class CharactersQuery(val page: Int? = null) : QueryOperation<CharactersQuery.Data> {
override val variables: Variables
get() = Variables.of("page" to page?.let { Variable.of(it) })
override val type: OperationType<Data> get() = Companion
override fun equals(other: Any?): Boolean = other is CharactersQuery && other.page == page
override fun hashCode(): Int = listOf(page).hashCode()
companion object : QueryType<CharactersQuery, CharactersQuery.Data> {
override val name = "CharactersQuery"
override val document: Document = Document.Text("query CharactersQuery(\$page:Int){characters(page:\$page){results{id,...CharacterRow_character}}}fragment CharacterRow_character on Character{name,status,species,image}")
override val kind = OperationKind.QUERY
override val plan: Plan by lazy { Plan(root = `CharactersQuery-plan`.selection0) }
override fun data(anchor: Anchor): Data = Data(anchor)
}
@Stable
class Data(override val anchor: Anchor) : Lens {
val characters: Characters? get() = anchor.linked(anchor.owner.slot(Slots.Query.characters_5517f9))?.let(::Characters)
override fun equals(other: Any?): Boolean = other is CharactersQuery.Data && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
@Stable
class Characters(override val anchor: Anchor) : Lens {
val results: List<Results>? get() = anchor.list(Slots.Characters.results, ::Results)
override fun equals(other: Any?): Boolean = other is CharactersQuery.Data.Characters && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
@Stable
class Results(override val anchor: Anchor) : Lens {
val id: String? get() = anchor.string(Slots.Character.id)
val characterRow: CharacterRow_character get() = CharacterRow_character(anchor.entering())
override fun equals(other: Any?): Boolean = other is CharactersQuery.Data.Characters.Results && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
}
}
}
}
private object `CharactersQuery-plan` {
val selection0: Selection by lazy {
Selection(type = Types.Query, key = listOf(), isAbstract = false, fields = listOf(
PlanField.linked("characters", key = StorageKey.Dynamic(Slots.Query.characters_5517f9), plural = false, selection = selection1),
))
}
val selection1: Selection by lazy {
Selection(type = Types.Characters, key = listOf(), isAbstract = false, fields = listOf(
PlanField.linked("results", key = StorageKey.Fixed(Slots.Characters.results), plural = true, selection = selection2),
))
}
val selection2: Selection by lazy {
Selection(type = Types.Character, key = listOf("id"), isAbstract = false, fields = listOf(
PlanField.scalar("id", key = StorageKey.Fixed(Slots.Character.id), kind = ScalarKind.STRING, list = false),
PlanField.scalar("name", key = StorageKey.Fixed(Slots.Character.name), kind = ScalarKind.STRING, list = false),
PlanField.scalar("status", key = StorageKey.Fixed(Slots.Character.status), kind = ScalarKind.STRING, list = false),
PlanField.scalar("species", key = StorageKey.Fixed(Slots.Character.species), kind = ScalarKind.STRING, list = false),
PlanField.scalar("image", key = StorageKey.Fixed(Slots.Character.image), kind = ScalarKind.STRING, list = false),
))
}
}Move the shared part to build time. The compiler aggregates fragments, applies arguments, inserts identity fields, interns storage keys, hashes persisted ids, and emits a normalization plan per operation and a lens per fragment. Both runtimes interpret the same plans, so most of the semantics is computed once, in one language, and the native code is a small interpreter plus the platform's own networking, observation and storage.
What remains written twice (the tokenizer, the store, retention, connections, the image) is specified in files no language owns: the runtime contract, which says what a runtime does with a plan, a response and a store, and the fixtures, recorded responses with the store contents and lens reads they must produce. A divergence is a failing test on both sides. The image's bytes are each runtime's own; what is shared is that a second store over the image reads the same records.
Consequences#
- The Swift runtime depends on Foundation, Observation and the SQLite the system ships; the Kotlin runtime on the platform's equivalents. No native library ships inside either.
- A platform developer can read, fix and vendor their runtime without the other platform's toolchain.
- Networking uses the platform stack, with its TLS, proxies, background behavior and HTTP/3.
- The compiler is the only Rust a user ever runs, and it runs on the build machine, never on the phone.
- Every runtime feature lands with its fixture, and the fixture is shared.
Not this#
- A shared Rust core with foreign-function bindings, however thin.
- A canonical store in one language mirrored by a read cache in another.
- A Kotlin Multiplatform core consumed from Swift, or a Swift core consumed from Kotlin.
- Any runtime behavior that exists in one platform's implementation and not in the fixtures.
See The compiler decides for what the shared build step owns, The response is the oracle for the fixtures, and the decision record Native runtimes, not a shared core for the measurements and the one condition under which this bends.
Spelled today#
swift/ holds the Swift runtime, its macros, its build plugin, its
benchmarks and its tests; kotlin/ holds the Kotlin runtime, common first
with the JVM and Android as its actuals, its Gradle plugin, its benchmarks
and its tests; compiler/ holds batonc, which emits Swift and Kotlin
from one plan; spec/ holds the schemas, the documents, the responses,
the store dumps and the expected reads both runtimes are held to, listed in
spec/manifest.json, and spec/runtime.md, the contract: the rules that
produce them, each naming the fixture that holds it or marked unheld.
Compose Multiplatform is the Kotlin runtime on more targets, not a third
runtime (The Kotlin runtime is common first).
This section may rot; the rest must not.
Baton