Makes the app's own native code safe to own on iOS and Android: native code only where no package
fits, written with the Expo Modules API in a local module under modules/<name>/, reached through one
typed, lazy, optional binding in its adapter behind a port, with the binding type and the Swift and
Kotlin definitions proven equal; Turbo Modules only where shared C++ or publishing needs them; and
every third-party native library either native on the New Architecture or a recorded decision. Two
zero-dependency checkers prove it.
Rules that must hold
- Native code comes last, and lives in a local Expo module. First an Expo SDK package, then a
library that is native on the New Architecture, then a config plugin; only then a local module made
by
npx create-expo-module@latest --local, in Swift and Kotlin with the Expo Modules API. No native source outsidemodules/<name>/(no inline modules, no edits to generatedios//android/) and no legacy bridge code (RCT_EXPORT_MODULE,ReactContextBaseJavaModule,@ReactMethod). Why: SDK 55 and later run only on the New Architecture, legacy modules run only through interop layers React Native will remove, and native code outside the module is never built or reviewed. (native-placement,legacy-native-api) - The module folder is complete.
expo-module.config.jsondeclares each platform and lists everyModuleclass (Swift class names, Kotlin full names); every listed class exists, extendsModuleand ispublicin Swift;ios/*.podspecdepends onExpoModulesCore;android/build.gradleappliesexpo-module-gradle-plugin. Why: autolinking registers only listed classes, and a missing piece fails one platform's build or leaves the module unreachable. (module-config,module-class,module-build-files) - One JavaScript name per module.
Name("TimeChange")is written explicitly, is identical in Swift and Kotlin, and is unique in the app. Why: the name is the lookup key; inferred or differing names find the module on one platform only. (module-name) - One binding per module, in its adapter, behind a port.
requireOptionalNativeModuleandrequireNativeViewappear only in the module's adapter,src/services/<concern>/native-<concern>-adapter.ts(the checker accepts any<library>-<concern>-adapter.ts(x)insrc/services, following architecture-and-boundaries), with a literal name a local module or spec defines; every module is bound, by exactly one adapter (platform variants-adapter.ios.ts/.android.tscount as one);TurboModuleRegistry.getappears only in a codegen spec (Native<Name>.ts); noNativeModules,requireNativeComponent,NativeEventEmitterorUIManager. The port, fake and composition root follow architecture-and-boundaries. Why: one line reaches native code, so one place guards it and everything above it runs in Jest on the fake; a second binding is a second, unguarded copy of the contract. (binding-place,binding-unique,binding-name,codegen-spec,legacy-js-api) - The binding is lazy and optional. The lookup runs in the factory's default parameter, never at
module scope, and uses
requireOptionalNativeModulefor both kinds of module (for a Turbo Module it falls back toTurboModuleRegistry.get): a missing module becomes a typedunavailablefailure. Why: the module is missing in Jest, on web, in a dev client or store build older than the module, and when an update reaches an older binary; the throwing forms crash at import there. (binding-lazy,binding-optional) - The binding type is the cross-language contract.
requireOptionalNativeModule<TimeChangeBinding>names a type declared in the adapter that lists every native function, property and event by its native name, and nothing else; Swift, Kotlin and the type change in one commit. Why: the Expo Modules API has no cross-platform codegen (expo-type-informationdrafts a type from Swift only), so a renamed member compiles everywhere and fails at runtime. For a Turbo Module the codegen spec is the contract: bindrequireOptionalNativeModule<Spec>(...)withimport type { Spec }from itsNative<Name>.ts; a hand-written type is compared with the spec. (binding-type,contract-drift) - Same surface on iOS and Android, or a declared difference. A member one platform lacks is
optional in the binding type (
isAutomaticTimeEnabledAsync?()) and the adapter maps its absence tounsupported; a one-platform module says so inplatforms. Why: an undeclared gap is "is not a function" on one platform only, found by users. (platform-parity) - Definitions stay thin, respect threads and release what they observe. Each closure calls a plain
helper that imports no Expo type;
Function/Property/Constantonly do cheap reads; UI work runs inAsyncFunction(...).runOnQueue(.main)(Queues.MAIN); OS observation starts inOnStartObserving, stops inOnStopObserving(same event, or the unnamed form) andOnDestroyin the Module itself, and every observer or receiver added is removed in the same file. Why: sync functions block the JavaScript thread, async ones default to a background queue, and a leaked observer keeps firing and keeps the module alive; a helper with a correctstop()still leaks when the Module never calls it. (main-thread,observer-release,observer-stop) - Turbo Modules only for shared C++ or publishing, built as a local package.
modules/<name>/withpackage.jsoncodegenConfig(name,type, ajsSrcsDirholding specs,android.javaPackageName), its podspec at the package root, specs namedNative<Name>.tsdeclaringexport interface Spec extends TurboModule; nevercodegenConfigin the app'spackage.json. Why: codegen reads only those files, autolinking looks for a React Native module's podspec only at its root, and CNG deletes app-level native code. (codegen-config,codegen-spec) - Every native dependency runs on the New Architecture natively, or is a recorded decision. A
dependency without
expo-module.config.json,codegenConfigor a Nitro marker has an entry incontracts/native-libraries.json(architecture, keep or replace, reason, evidence,recheckBy); entries stay true and are rechecked by their date. Why: such a library runs through the interop layer, whose limits and removal the app must plan for, not discover. (library-legacy,library-entry,library-stale,library-recheck) - expo-doctor's React Native Directory check stays on. No
enabled: false; every exact-nameexcludehas a decision entry; no regex exclusions. Why: the directory check is the only automatic warning for unmaintained or New-Architecture-incompatible packages. (doctor-check) - Proven on both platforms before done; a native change is a new binary. Jest covers the fake and
the adapter's mapping;
npx expo-modules-autolinking resolvelists the module for apple and android; the Swift type-checks and the Kotlin helpers compile (testing-and-proof.md); a dev client on a named simulator and emulator exercises every member. Why: Fast Refresh and over-the-air updates never carry native code, and each layer catches what the others cannot. - Stop and ask before a new native module, a Turbo Module instead of an Expo module, keeping an
interop library, an expo-doctor exclusion, or a
nativeSourceDirsentry. Why: each binds the app for every later SDK and OS move.
Workflow
- Decide whether native code is needed. Read references/decisions.md sections 1 to 4; walk the order of reach. A native package: go to step 7. Ask the developer before writing a module (rule 13).
- Create the module. From the app root:
EXPO_NONINTERACTIVE=1 npx create-expo-module@latest --local <name> --name <Pascal> --platform apple android --features Function AsyncFunction Event. Delete itssrc/folder (the binding lives in the adapter). Shape it liketemplates/modules/time-change/(config, podspec, Gradle file, a thin<Pascal>Moduleper platform over a plain<Pascal>Systemhelper). API details: references/expo-modules-api.md. - Write the port, fake and adapter. Copy
templates/src/services/time-change/as the pattern:<concern>-port.ts(Result failures, unsubscribe),fake-<concern>.ts+ test first, thennative-<concern>-adapter.tswith the binding type, the lazy optional binding and the error mapping, and its test against a stand-in native object. Wire it as examples/wiring-the-port.md shows (architecture-and-boundaries owns the composition root). - Need shared C++ or a module for non-Expo apps? Read references/turbo-modules-and-codegen.md and follow examples/turbo-module/ instead of steps 2 and 3's module half.
- Check the contract:
node ${CLAUDE_SKILL_DIR}/scripts/check-native-modules.mjs .; fix everyFAILline and rerun. - Prove it builds and links (references/testing-and-proof.md):
npm test,npx expo-modules-autolinking resolve --platform apple --jsonand--platform android, the Swift type-check, the Kotlin helper compile; then a dev client on a named iOS simulator and Android emulator (ios-simulator-build, android-emulator-build), every member on both, and the manual check for OS events. - Check native libraries. After
npm ci:node ${CLAUDE_SKILL_DIR}/scripts/check-native-libraries.mjs .andnpx expo-doctor. Aninteroplibrary: replace it, wrap what you need in a module, or (after asking) record it incontracts/native-libraries.jsonfromtemplates/contracts/(examples/native-libraries.json; references/library-compatibility.md). - Run both checks until each prints
RESULT: PASS.
Definition of done
- No package covered the need, and the developer agreed to native code; the module is in
modules/<name>/, written with the Expo Modules API (or a Turbo Module package for shared C++ or publishing), with no native source elsewhere. -
expo-module.config.json, podspec and Gradle file complete; one explicitName()on both platforms. - One typed, lazy, optional binding in
native-<concern>-adapter.ts; the binding type lists exactly the native members and events; platform gaps optional and mapped tounsupported. - Port, fake (+ test) and adapter test in Jest;
npm testpasses. -
npx expo-modules-autolinking resolvelists the module for apple and android; the Swift type-checks; a dev client ran every member on a named simulator and emulator. - Every interop library has a current decision entry; expo-doctor's directory check is on.
-
node ${CLAUDE_SKILL_DIR}/scripts/check-native-libraries.mjs .printsRESULT: PASS(afternpm ci;note:lines name transitive interop packages with their parent, and native devDependencies). -
node ${CLAUDE_SKILL_DIR}/scripts/check-native-modules.mjs .printsRESULT: PASS
Neither checker prints SKIP lines. Each prints NOT APPLICABLE when expo (modules) or react-native
(libraries) is not a dependency. check-native-libraries exits 2 without node_modules.
Anti-patterns
export default requireNativeModule<...>('X')ascreate-expo-modulewrites it. It runs at import and throws in Jest, on web and in older binaries; bind in the adapter factory withrequireOptionalNativeModule.import { NativeModules } from 'react-native', orrequireOptionalNativeModulein a screen or hook. Native code then reaches untested layers; take the port fromuseServices().- An untyped binding, or a type that "roughly" matches. Nothing catches
getTimeZoneversusgetTimeZoneIduntil a user calls it; list the native members exactly. - A function added to Kotlin only. It works on Android and is "not a function" on iOS; implement it on both, or make it optional in the binding and map the gap.
- Business logic inside
definition()closures. It cannot be compiled, read or tested alone; call a plain helper. addObserverinOnStartObservingwith noremoveObserver, or a helper'sstop()the Module never calls. Events keep arriving after the screen closed; stop inOnStopObservingandOnDestroy.- A second adapter that binds a module already bound. Two copies of the contract drift apart; take the first adapter's port instead.
- UIKit in a
Function, or in anAsyncFunctionwithout.runOnQueue(.main). Main Thread Checker warnings in debug, corrupted UI in release. - Swift or Kotlin beside screens through
experiments.inlineModules. Experimental, outside every rule and review; usemodules/<name>/. - A Turbo Module "for performance" with no C++ to share. The Expo Modules API is JSI-based too, with far less code; codegen earns its cost only for shared C++ or publishing.
codegenConfigin the app's package.json. Its native half would live in generated folders that a clean prebuild deletes.- Silencing expo-doctor with
enabled: falseor"/react-native-.*/". It hides every future warning; exclude exact names with a recorded decision. - Testing native behaviour only in Jest. Jest proves the mapping; only a dev build proves the OS sends what the module claims.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/decisions.md | The order of reach, Expo module vs Turbo Module vs legacy vs inline, file layout, the binding design, platform gaps, views, native changes and updates | Workflow step 1; any design question |
| references/expo-modules-api.md | create-expo-module output, the config, definition components, types, threads, events, errors, lifecycle, views, the JavaScript side, iOS vs Android | Workflow step 2 |
| references/turbo-modules-and-codegen.md | Turbo Modules as local packages, specs, codegenConfig, native halves, linking proof, Fabric components | Workflow step 4 |
| references/library-compatibility.md | Interop facts for React Native 0.86, classifying packages, expo-doctor's directory check, the decisions file, transitive packages | Workflow step 7 |
| references/testing-and-proof.md | Jest layers, the contract check, autolinking proof, Swift type-check and Kotlin compile recipes, device checks, problems and fixes | Workflow steps 3, 5 and 6 |
templates/modules/time-change/ |
A complete local Expo module: config, podspec, Gradle file, manifest; Swift and Kotlin definitions over plain helpers (time zone, clock and day changes) | Workflow step 2 (copy the shape, or adopt it) |
templates/src/services/time-change/time-change-port.ts |
The port: Result failures, an unsubscribe for events | Workflow step 3 |
templates/src/services/time-change/native-time-change-adapter.ts |
The one binding: binding type, lazy optional lookup, error and event mapping | Workflow step 3 |
templates/src/services/time-change/native-time-change-adapter.test.ts |
The adapter against a stand-in native object, and the null lookup under Jest | Workflow step 3 |
templates/src/services/time-change/fake-time-change.ts |
The port's fake with emit, listener count and a call log | Workflow step 3 |
templates/src/services/time-change/fake-time-change.test.ts |
Its test | Workflow step 3 |
templates/contracts/native-libraries.json |
The empty decisions file | Workflow step 7 |
templates/contracts/native-libraries.schema.json |
Its JSON schema | Workflow step 7 |
| examples/wiring-the-port.md | Services, composition root, test services, a model hook that subscribes | Workflow step 3 |
| examples/turbo-module/ | A local Turbo Module package: spec, codegenConfig, root podspec, Objective-C++, Kotlin, package, shared C++ reached through JNI and CMake on Android | Workflow step 4 |
| examples/native-libraries.json | A filled decisions file (keep and replace) | Workflow step 7 |
scripts/check-native-modules.mjs |
Checks modules (config, classes, build files, names, parity, threads, observers, legacy code), bindings and the contract, codegen, placement | Workflow step 5; definition of done |
scripts/check-native-libraries.mjs |
Classifies installed native dependencies; checks the decisions file and expo-doctor's directory check | Workflow step 7; definition of done |
scripts/lib/native-scan.mjs |
Swift/Kotlin class and definition reader, binding and binding-type parser, package classifier | Only when changing a checker |
scripts/lib/source-scan.mjs |
Dependency-free lexer (synced from the library; do not edit here) | Only when changing a checker |
scripts/check-lib.mjs |
Shared script helper (synced from the library; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers pass the templates and example and catch every planted bug | After changing a checker |
assets/shared.json |
Declares the synced files | When adding a shared file |
tests/ |
The base apps and the overlay cases (one planted bug per rule) | When adding a rule |
Related skills
architecture-and-boundaries- ports, adapters, fakes, the composition root;modules/<name>/as an SDK only its adapter reaches.expo-prebuild-and-config-plugins- Info.plist, manifest and entitlement changes a module needs; generated folders.dependency-management- installing and pinning a native library before this skill's compatibility check.expo-sdk-upgrade- SDK moves, removednewArchEnabled, rechecking interop decisions.unit-and-component-tests- render helpers and test services for code above the adapter.error-handling-typed-results- the Result and failure kinds the port returns.async-safety- effect cleanup for subscriptions to module events.ios-simulator-build,android-emulator-build- dev builds on named devices.e2e-maestro- flows that exercise native features on both platforms.ota-updates- runtime versions: why native changes need a new binary.permissions-and-system-prompts- a module that needs a runtime permission asks through that port.troubleshooting-playbook- the wider error catalog.