One Logo Pool Ball
wwwwwwwwwwwwwwwwwww

Swift and Kotlin

Import Swift and Kotlin source files from TypeScript and call them as typed functions and components

An app can import its own Swift and Kotlin files. One reads each file’s declarations, writes a TypeScript declaration next to it, and generates the native glue that one prebuild compiles into the app. There is no module boilerplate, no bridge registration and no codegen step to run by hand.

import { Device } from './native/Showcase/Device.swift'
const hash = await Device.sha256('hello')

Swift imports run on iOS and Kotlin imports run on Android. Native source requires a project generated by one prebuild; apps on Expo prebuild follow Coming from Expo.

Swift functions

Put Swift files in a Swift package: any directory under the app with a Package.swift. The directory name names the package, so keep it unique and different from the app’s own name. A nested JavaScript package (a directory with its own package.json) counts only when its root holds the Package.swift; Swift packages deeper inside it belong to that package.

native/Showcase/Package.swift
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "Showcase",
platforms: [.iOS(.v17)],
targets: [
.executableTarget(name: "Showcase", path: ".", swiftSettings: [.swiftLanguageMode(.v6)]),
]
)

A class that conforms to PeachModule becomes a named export. Each method that is not private or fileprivate becomes an async function:

native/Showcase/Device.swift
import CryptoKit
import UIKit
@MainActor final class Device: PeachModule {
init() {}
func info() -> [String: String] {
["model": UIDevice.current.model, "system": UIDevice.current.systemVersion]
}
func sha256(_ text: String) -> String {
SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined()
}
}
import { Device } from './native/Showcase/Device.swift'
await Device.info() // Promise<Record<string, string>>
await Device.sha256('hello') // Promise<string>

Methods may be async and may throw; a thrown error rejects the promise. The package’s sources may import Apple frameworks. SwiftPM package dependencies are not supported; add native libraries as React Native dependencies instead.

Swift views

A public struct that conforms to View becomes a named React component. Its stored properties are the props, and a closure property is a callback:

native/Showcase/Level.swift
import SwiftUI
public struct Level: View {
let value: Double
let onChange: (Double) -> Void
public var body: some View {
Slider(value: Binding(get: { value }, set: onChange))
}
}
import { Level } from './native/Showcase/Level.swift'
<Level value={value} onChange={setValue} />

The view is built through its memberwise initializer, so it has no init of its own. A property that already has a value, such as @State private var dragging = false, stays the view’s own. A property typed T? is an optional prop, and a var may only default to nil. @Binding is not a prop; take a value and a callback instead. Views that are not public stay inside the package.

A package as one view

The package’s @main type is the default export of its file when it conforms to PeachPackage. The element’s props arrive as untyped JSON:

@main struct Badge: PeachPackage {
func view(props: JSON) -> some View {
Text(props["title"]?.stringValue ?? "")
}
}
import Badge from './native/Badge/Badge.swift'
<Badge title="new" />

A package has at most one @main type. A whole SwiftUI app (@main struct MyApp: App) imported this way renders its scene’s root view and fills the space it is given.

Kotlin functions and views

A Kotlin file can sit anywhere in the app outside android, ios, node_modules and nested packages. Packages declared in the build root’s workspaces are included; their source IDs use paths relative to that build root. An object that implements OneModule becomes a named export, and each of its public functions an async function. suspend functions are awaited. A public @Composable function with a capitalized name becomes a React component whose props are its parameters; function-typed parameters are callbacks. Views compile against Compose UI, Foundation, Material 3 and Navigation.

native/Device.kt
import android.os.Build
import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.onejs.one.source.OneModule
import java.security.MessageDigest
object Device : OneModule {
fun info(): Map<String, String> =
mapOf("model" to Build.MODEL, "system" to Build.VERSION.RELEASE)
fun sha256(text: String): String =
MessageDigest.getInstance("SHA-256")
.digest(text.toByteArray())
.joinToString("") { "%02x".format(it) }
}
@Composable
fun Level(value: Double, onChange: (Double) -> Unit, modifier: Modifier = Modifier) {
Slider(value = value.toFloat(), onValueChange = { onChange(it.toDouble()) }, modifier = modifier)
}
import { Device, Level } from './native/Device.kt'
<Level value={value} onChange={setValue} />

A composable’s Modifier parameter must default to Modifier; One supplies it. Other parameters may only default to null, which makes the prop optional. The composable sizes to its content inside the surrounding layout.

Types

These types cross the boundary as JSON:

SwiftKotlinTypeScript
StringStringstring
BoolBooleanboolean
Double, Float, IntDouble, Float, Int, Longnumber
[T]List<T>T[]
[String: T]Map<String, T>Record<string, T>
T?T?T | null
VoidUnitvoid

These types also cross as view props and callback arguments. Integer arguments must be whole numbers in range, and Long results must fit in JavaScript’s safe integer range. Overloads, generics, default arguments and static methods are rejected with the file and line.

The declaration sits beside the source as Device.d.swift.ts or Device.d.kt.ts. The dev server and one prebuild rewrite it whenever they read the file, and one typegen writes all of them. TypeScript reads these declarations when allowArbitraryExtensions is enabled in tsconfig.json.

One import on every platform

A .swift import only bundles for iOS and a .kt import only for Android. To use one API everywhere, give the platforms their own entry files and keep the web implementation in TypeScript:

features/native/device.ios.tsx export { Device } from '../../native/Showcase/Device.swift'
export { Level } from '../../native/Showcase/Level.swift'
features/native/device.android.ts export { Device, Level } from '../../native/Device.kt'
features/native/device.tsx export const Device = { ... } // web

Screens import features/native/device and each platform’s bundler picks its file. The starter app created by npx one includes this example at the /native route.

Rebuilds

Native source compiles into the app binary. After adding or editing a Swift or Kotlin file, run one prebuild and rebuild the app. Each generated function carries a hash of its native contract: if JavaScript reloads against a binary built from a different contract, calls reject with E_NATIVE_SOURCE_REBUILD instead of reaching mismatched code. OTA updates follow the same rule; see native rebuilds and updates.

Edit this page on GitHub.