skip to content

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.