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.
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.
// swift-tools-version: 6.2import 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:
import CryptoKitimport 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.
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:
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.
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.
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.
import android.os.Buildimport androidx.compose.material3.Sliderimport androidx.compose.runtime.Composableimport androidx.compose.ui.Modifierimport dev.onejs.one.source.OneModuleimport 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) }}
@Composablefun 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.
These types cross the boundary as JSON:
| Swift | Kotlin | TypeScript |
|---|---|---|
String | String | string |
Bool | Boolean | boolean |
Double, Float, Int | Double, Float, Int, Long | number |
[T] | List<T> | T[] |
[String: T] | Map<String, T> | Record<string, T> |
T? | T? | T | null |
Void | Unit | void |
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.
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 = { ... } // webScreens 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.
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.