Migrating a custom native module to a TurboModule
· 4 min read · Amrith Vengalath
- React Native
- New Architecture
- TurboModules
- Native Modules
I wrote a post a while back about building a native module the old bridge way. With the New Architecture now the default, the natural follow-up is converting one of those to a TurboModule. I did this for a small module - the device-language reader from that earlier post - and the process is more structured than the old bridge, which is a good thing once you get past the initial "where does this file go."
Here's the conversion, concretely.
What's actually different
The old bridge module was loosely typed - you exposed methods and hoped the JS and native sides agreed on the signatures. A TurboModule flips this around: you write a typed spec in JavaScript/TypeScript, and codegen generates the native interfaces from it. The JS and native sides can't drift, because the native side is generated from the spec the JS side uses. You also get lazy loading and the synchronous-capable JSI path for free.
So the new piece is the spec file plus codegen wiring; the native implementation is similar in spirit to before, just conforming to a generated interface.
Step 1: the spec file
The spec is a TypeScript file with a strict shape and a naming convention (Native<Name>). This is the contract:
// NativeDeviceInfo.ts
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";
export interface Spec extends TurboModule {
getPreferredLanguage(): Promise<string>;
}
export default TurboModuleRegistry.getEnforcing<Spec>("DeviceInfoModule");getEnforcing means "this module must exist; throw clearly if it doesn't" - much nicer than the old silent undefined when a name didn't match.
Step 2: wire up codegen
You declare the codegen config in package.json (for a library) or the app config, telling React Native where the specs live and what to generate:
"codegenConfig": {
"name": "DeviceInfoSpec",
"type": "modules",
"jsSrcsDir": "./src"
}When you build, codegen reads the spec and produces the native base classes/interfaces your implementation conforms to. This is the part that guarantees the type safety end to end.
Step 3: the native implementations
The implementations now conform to the generated spec. On iOS you implement the generated protocol; on Android you extend the generated abstract class. The actual logic - reading the locale - is the same as the bridge version; what changed is the base it inherits from and that it's a TurboModule.
Android side, conceptually:
class DeviceInfoModule(reactContext: ReactApplicationContext) :
NativeDeviceInfoSpec(reactContext) { // generated base class
override fun getName() = "DeviceInfoModule"
override fun getPreferredLanguage(promise: Promise) {
val lang = reactApplicationContext.resources
.configuration.locales[0].language
promise.resolve(lang)
}
}The shape is familiar if you've written a bridge module - the difference is NativeDeviceInfoSpec is generated from your TypeScript spec, so the method signature is enforced rather than hoped-for.
Step 4: call it, identically
The JavaScript usage barely changes, which is the point - consumers of your module shouldn't have to care:
import DeviceInfo from "./NativeDeviceInfo";
const lang = await DeviceInfo.getPreferredLanguage();What tripped me up
- Codegen runs at build time, so you rebuild. If you change the spec and don't see the new method, you didn't rebuild the native side. Same old lesson, new context.
- The spec naming and location are strict.
Native<Name>.ts, in the configured directory. Codegen won't find a spec that's named or placed casually. - Interop exists, but it's a bridge to the past, not the destination. The compatibility layer runs old modules under the New Architecture, which is great for incremental migration. But the goal is converting them, not leaning on interop forever.
- Types are now load-bearing. With the bridge I could be sloppy about argument types. With codegen, the spec is the source of truth and a wrong type is a build problem, not a runtime surprise. This is strictly better, but it's an adjustment if you were used to the loose old way.
Worth it?
For a module you maintain and ship, yes - it's the supported path now, you get lazy loading and the performance foundation, and the type safety genuinely catches the JS-vs-native mismatches that used to be runtime crashes. For a one-off internal module, the interop layer buys you time to do it when convenient. But the direction is clear, and converting a small module first is a great way to learn the moving parts before you tackle a complicated one.