skip to content

When designing a React Native Turbo Module spec, when should a method return a Promise, take a callback, or be an EventEmitter instead?

level: middleimportance: must knowfreq 50%

answer

  1. how many results, and when
  2. Promise: one result or one error
  3. callback arg: invoked at most once
  4. EventEmitter: many values over time
  5. sync return only for cheap values

basics

~20 s

Return a Promise for one asynchronous result that can fail, declare an EventEmitter for many values over time, and treat a callback as a one-shot legacy alternative; a plain return value suits only cheap, immediate data.

solid answer

~40 s

Pick the shape by how many results arrive and when. A `Promise<T>` return fits one asynchronous outcome with success or failure, such as connecting to a heart-rate strap: native code calls `resolve` or `reject(code, message)` once and JavaScript gets `await` plus an `Error` carrying `code`. A callback parameter is also one-shot: a Turbo Module callback may be invoked only once, and invoking it again is a fatal error, so it is not a streaming channel and has no built-in error path. A `CodegenTypes.EventEmitter<T>` property is the shape for many values over time, such as one reading per second. A method with a plain return type runs synchronously on the JS thread, so it suits only cheap, already-known values.

code

typescript · 15 lines
typescript
import type {TurboModule, CodegenTypes} from 'react-native';
import {TurboModuleRegistry} from 'react-native';

export type HeartRateSample = {bpm: number; timestamp: number};

export interface Spec extends TurboModule {
  isBluetoothEnabled(): boolean;
  connect(deviceId: string): Promise<boolean>;
  startStreaming(): void;
  stopStreaming(): void;
  readonly onHeartRate: CodegenTypes.EventEmitter<HeartRateSample>;
  readonly onDisconnected: CodegenTypes.EventEmitter<string>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeHeartRate');

go deeper

for a junior

Recall the rule of thumb: one result means a Promise, many results over time mean an event emitter, and a plain return value is only for instant data.

for a middle

Explain how each shape appears natively: a trailing Promise or resolve and reject blocks, a one-shot callback object, and a generated emit method for events.

for a senior

Design the full surface for a streaming device: sync flags, Promise-based connect, explicit start and stop, separate error or disconnect events, and error codes the UI can branch on.

for a principal

Judge API stability for a shared module used by several apps: which calls may block the JS thread, how error codes are versioned, and how streams are stopped when screens disappear.

## Four shapes a spec method can take A **Turbo Module spec** is the typed TypeScript (or Flow) interface that Codegen turns into native base classes. Each member of the spec has one of four shapes, and the shape decides how the result travels back to JavaScript: | Shape | Spec syntax | Results | Error channel | Runs | |---|---|---|---|---| | Sync return | `getLastBpm(): number` | one, immediately | a thrown error at the call site | on the JS thread, blocking it | | Promise | `connect(id: string): Promise<boolean>` | one, later | `reject` gives an `Error` with `code` | off the JS thread | | Callback | `scan(onDone: (ids: string[]) => void): void` | one, later | none built in | off the JS thread | | Event emitter | `readonly onHeartRate: CodegenTypes.EventEmitter<Sample>` | many, over time | none; add an error event | emitted from any native thread | ## Promise: one outcome that can fail Connecting to a Bluetooth heart-rate strap either succeeds or fails, once. That is a **Promise** method. On Android the generated method gains a trailing `Promise` parameter; on iOS it gains `resolve:` and `reject:` blocks. Native code calls `resolve(value)` or `reject(code, message)` exactly once. JavaScript receives a normal Promise; a rejection arrives as an `Error` object whose `code` property carries the native code, so the screen can branch on `'E_NOT_PAIRED'` versus `'E_TIMEOUT'`. Calling `resolve` twice does not deliver two values: only the first call settles the Promise, and later calls are dropped (iOS also logs an error; Android ignores them silently). That is the tell that a Promise is the wrong shape for a stream. Since React Native 0.82, a rejection nobody handles is reported through `console.error` rather than silently swallowed. ## Callback: one-shot, and not a stream A function-typed parameter becomes a native callback object (`Callback` on Android, `RCTResponseSenderBlock` on iOS). In a Turbo Module each callback argument may be invoked **once**; a second invocation is treated as a fatal error in the native runtime. Callbacks have no built-in error path either: by convention libraries pass an error as the first argument. Callbacks survive mostly in modules ported from the legacy architecture. For new code a Promise does the same job with `async`/`await` and typed rejections. ## Event emitter: many values over time A strap that reports a reading every second is a **stream**, so the spec declares `readonly onHeartRate: CodegenTypes.EventEmitter<HeartRateSample>`. JavaScript subscribes with a handler and gets an `EventSubscription`; native code calls the generated `emitOnHeartRate` method as often as it likes, from any thread. Emissions reach only handlers subscribed at that moment, and the emitter tells native code nothing about listeners coming or going, so a stream usually comes with explicit `startStreaming()` and `stopStreaming()` methods, which are themselves `void` or Promise methods. ## Sync return: cheap and immediate only A method whose return type is a value (not `void`, not `Promise`) is **synchronous**: it runs on the JS thread and JavaScript waits for it. That is ideal for something already in memory, such as the last cached reading, and wrong for anything that touches the radio, disk or network. ## How errors travel in each shape - **Sync return:** a native exception becomes a JavaScript error thrown at the call site. - **Promise:** native code calls `reject(code, message)`; `await` throws an `Error` carrying `code`. - **Callback:** nothing built in; the usual convention is an error-first argument, which Codegen cannot enforce. - **Event emitter:** no error channel at all, so failures mid-stream need their own event, such as `onDisconnected`. ## A worked design for the cycling app 1. `isBluetoothEnabled(): boolean` returns a cached flag synchronously. 2. `connect(deviceId: string): Promise<boolean>` resolves on connection and rejects with a code on failure. 3. `startStreaming(): void` and `stopStreaming(): void` switch notifications on and off. 4. `onHeartRate` emits each sample; `onDisconnected` emits when the link drops. ## How interviewers probe this - "Why not a callback for readings?" A callback can fire once. - "Why not a Promise per reading?" A Promise settles once; you would need a new call per sample. - "How do you report a mid-stream failure?" Declare a second emitter, such as `onError` or `onDisconnected`, because emitters have no error channel.

  • What does JavaScript see when native code rejects with the code 'E_NOT_PAIRED'?
    The awaited Promise throws an `Error` whose `message` is the native message and whose `code` property is `'E_NOT_PAIRED'`. Both platforms build the JS error from the code and message, so the screen can branch on `error.code` instead of parsing text.
  • What happens if native code calls resolve twice on the same Promise?
    Only the first call settles the Promise. Later resolve or reject calls are dropped: iOS logs an error, while Android ignores them silently. If a feature needs to deliver more than one value, the spec should expose an event emitter instead of re-resolving.
  • Why is a callback argument a poor fit for heart-rate readings?
    A Turbo Module callback argument can be invoked only once; calling it again is a fatal native error. A stream of readings needs an event emitter, which can emit any number of times to every current subscriber.

saying these in an interview costs you the question

  • A callback parameter can be invoked repeatedly to stream values
  • Resolving a Promise again sends a second value to JavaScript
  • Every Turbo Module method is asynchronous, whatever its return type
  • Event emitters have a built-in rejection channel for errors
  • Rejections arrive in JavaScript as plain strings with no code
  • Sync return methods are the fastest choice for any native call