Baton

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()) }
            }
        }
    }
}
Plate 1. One plan, two emitters: the same operation's generated code in Swift and in Kotlin.

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.