skip to content

Custom Swift & Kotlin APIs

The Expo Modules API declares a native module in a Swift and Kotlin DSL instead of hand-wired specs and glue. Interviewers ask how it compares with a Turbo Module and how it exposes events and views.

part ofExpo (React Native)overview, primer and where to startread it →
on this pageshow

explore

questions

5

When would you write a native module with the Expo Modules API rather than a spec-based Turbo Module, and how do the two differ?

level: middleimportance: must knowfreq 45%

answer

  1. where the interface is declared
  2. Swift and Kotlin DSL, no Codegen spec
  3. both sit on JSI
  4. C++ points to Turbo Modules
  5. depends on the expo package

basics

~20 s

The Expo Modules API declares a module in a Swift and Kotlin DSL and validates arguments at runtime; a Turbo Module starts from a TypeScript spec that Codegen turns into native interfaces. Both run over JSI; C++ work favours Turbo Modules.

solid answer

~50 s

Both give JavaScript direct JSI access to native code, and Expo's docs describe their performance as similar, so the choice is about authoring. A Turbo Module starts from a TypeScript or Flow spec; Codegen generates the native interfaces, which you implement in Objective-C++ and Kotlin or Java, and you can drop into C++. An Expo module is declared inside a Swift or Kotlin class with a `ModuleDefinition` DSL (`Name`, `Function`, `AsyncFunction`, `Events`, `View`), and the API converts and validates arguments, including `Record` structs and `Enumerable` enums. The cost is a dependency on the `expo` package and hand-written TypeScript types. The React Native team's guidance, as Expo's docs paraphrase it: C++ in the module means Turbo Modules; otherwise the Expo Modules API is the more comfortable choice. In an Expo app it is the default route for custom native code.

code

swift · 28 lines
swift
import ExpoModulesCore
import UIKit

public class HapticPatternsModule: Module {
  public func definition() -> ModuleDefinition {
    Name("HapticPatterns")

    // Arguments are converted and validated from the closure's types.
    AsyncFunction("impactAsync") { (style: ImpactStyle) in
      let generator = UIImpactFeedbackGenerator(style: style.feedbackStyle)
      generator.prepare()
      generator.impactOccurred()
    }
    .runOnQueue(.main)
  }

  enum ImpactStyle: String, Enumerable {
    case light, medium, heavy

    var feedbackStyle: UIImpactFeedbackGenerator.FeedbackStyle {
      switch self {
      case .light: return .light
      case .medium: return .medium
      case .heavy: return .heavy
      }
    }
  }
}

go deeper

for a junior

Recall that both let JavaScript call native code, that Turbo Modules start from a spec and Codegen, and that Expo modules are written in a Swift and Kotlin DSL.

for a middle

Explain the authoring difference, spec plus Codegen against DSL plus runtime argument conversion, and name the dependency and C++ trade-offs.

for a senior

Choose for a concrete case, such as an app-specific haptics API in an Expo app or a C++-heavy library for bare apps, and explain the type-drift risk and how you contain it.

for a principal

Set the team default for native code: which model new modules use, when the expo dependency is acceptable for a library, and who owns keeping native and TypeScript contracts aligned.

## Two ways to reach native code from JavaScript On the New Architecture, JavaScript calls native code through **JSI**, the JavaScript Interface that lets the runtime hold references to native objects and call them directly, without the old serialized message queue. Two authoring models sit on top of it: - **Turbo Native Modules**, React Native's own system, built around **Codegen**. - **The Expo Modules API**, Expo's abstraction for Swift and Kotlin, used by the Expo SDK's own packages such as `expo-haptics`, `expo-camera` and `expo-location`. Expo's documentation states that the two have similar performance characteristics: both use JSI, and in practice the time spent inside a native method usually dwarfs the call overhead. ## How each one is declared | aspect | Turbo Module | Expo module | |---|---|---| | source of the interface | a TypeScript or Flow **spec** file | a **`ModuleDefinition`** DSL in Swift and Kotlin | | generated code | Codegen produces native interfaces and glue | none required; the DSL is read at runtime | | iOS language in the official guide | Objective-C++ (`.mm`) | Swift | | Android language | Kotlin or Java | Kotlin | | C++ | first-class, including pure C++ modules | not the target | | TypeScript types | derived from the spec | written by hand as a class extending `NativeModule` | | dependency | React Native core | the `expo` package | With a Turbo Module, the spec is the contract and Codegen enforces it at build time. With an Expo module, the Swift or Kotlin closure signatures are the contract, and the API **converts and validates arguments** at call time: primitives, arrays, `Record` structs with typed `@Field` members, `Enumerable` enums and convertible platform types such as `URL` or colours. ## What the Expo Modules API adds The Expo docs list the design goals behind it: 1. **Modern languages only**: Swift and Kotlin, with optionals, instead of Objective-C and hand-validated maps. 2. **One API for both platforms**: the same DSL names (`Name`, `Constant`, `Property`, `Function`, `AsyncFunction`, `Events`, `View`, `Prop`) on iOS and Android. 3. **Typed data passing**: `Record` and `Enumerable` replace per-key checks on untyped dictionaries. 4. **Lifecycle hooks without editing app files**: AppDelegate subscribers and Android lifecycle listeners let a module react to app events without the app changing its `AppDelegate` or `MainActivity`, which fits Continuous Native Generation. 5. **Views in the same module**: a `View` block with `Prop`, `Events` and view `AsyncFunction`s declares a native view next to the module's functions. ## When to choose which The Expo docs paraphrase the React Native team's recommendation: - **You need C++**, for example shared C++ logic, a pure C++ module or low-level access to the runtime: use **Turbo Modules**. - **You want the smoother developer experience and accept depending on `expo`**: use the **Expo Modules API**. Practical signals that tip it one way or the other: - An Expo app with Continuous Native Generation already depends on `expo`, so a **local Expo module** is the default for app-specific native code, such as a custom haptics pattern API. - A library meant for bare React Native apps that do not want the `expo` dependency, or one that is mostly C++, fits Turbo Modules better. - A team that writes Swift but not Objective-C++ gets further, faster, with the Expo DSL. ## Coexistence in one app The two models are not exclusive. A typical Expo app already runs dozens of Expo modules from the SDK next to community libraries written as Turbo Modules, and both are reached through JSI. Expo's own loader reflects this: `requireNativeModule` first looks for an Expo module registered under the name and, failing that, falls back to `TurboModuleRegistry`. In practice: - adding a local Expo module does not require converting existing Turbo Module dependencies; - a module can start as a local Expo module and later be rewritten as a Turbo Module if C++ becomes necessary, keeping the JavaScript-facing API stable; - the choice is per module, not per app. ## Traps in the comparison - Saying Expo modules use **the old bridge** is wrong: they run on JSI. - Saying Turbo Modules are **much faster** is not supported by either side's docs for normal module calls. - Saying Expo modules **need no TypeScript** is wrong: without a spec you write the TypeScript declaration yourself, so it can drift from the native signature. Expo SDK 56 added `expo-type-information`, a macOS-only tool that generates TypeScript interfaces from Swift modules, which narrows that gap.

  • Is an Expo module slower than a Turbo Module because it has no generated code?
    Not measurably for normal calls. Expo's docs say both use JSI and have similar performance, and that the work inside a native method usually outweighs the call overhead by orders of magnitude. A hot path that truly needs minimal overhead, or C++, is the case for a Turbo Module.
  • What keeps an Expo module's TypeScript types in sync with its native code?
    Nothing automatic by default: you declare a class extending `NativeModule` by hand and pass it to `requireNativeModule`. Keep the declaration next to the module and review both together. From SDK 56, `expo-type-information` can generate the interface from Swift modules on macOS.

saying these in an interview costs you the question

  • Expo modules go through the old asynchronous bridge instead of JSI.
  • Turbo Modules are much faster than Expo modules for ordinary calls.
  • The Expo Modules API is the right choice when the module is mostly C++.
  • An Expo module needs a Codegen spec just like a Turbo Module.
  • Expo modules only work inside Expo Go.
open as a page

In an Expo module's ModuleDefinition, how do Constant, Property, Function and AsyncFunction differ in what JavaScript sees and where they run?

level: middleimportance: should knowfreq 35%

basics

~20 s

Constant exposes a value computed once and cached; Property runs its getter, and optional setter, on each access; Function is synchronous and blocks JavaScript; AsyncFunction returns a Promise and runs off the JavaScript thread by default.

open as a page

How does an Expo module deliver native events to JavaScript with Events and sendEvent, and how do view events differ?

level: seniorimportance: should knowfreq 25%

basics

~20 s

An Expo module lists its event names with Events and emits them with sendEvent(name, payload); JavaScript subscribes through the module's addListener or the useEvent hooks. A view event instead uses an EventDispatcher property and arrives as a prop callback with nativeEvent.

open as a page

How do you expose a native view from an Expo module with View and Prop, and how does JavaScript render and call it?

level: seniorimportance: should knowfreq 20%

basics

~20 s

Declare View(MyView) inside the module definition with a Prop setter per prop, Events for callbacks and optional AsyncFunctions; the view class extends ExpoView. JavaScript gets a component from requireNativeView('ModuleName') and calls view functions through a ref.

open as a page

In an Expo app, what does npx create-expo-module --local set up, and what does expo-module.config.json tell the build?

level: juniorimportance: nice to knowfreq 20%

basics

~20 s

npx create-expo-module --local scaffolds a module under the app's modules directory with Swift, Kotlin and TypeScript files and an expo-module.config.json, which lists the platforms and the native module classes autolinking registers. Native changes need a rebuild.

open as a page