A JS bundle can run on a binary that does not contain every native module the bundle mentions. That happens in three situations: A static import at the top of a file runs when the file is first evaluated. If the package’s entry calls requireNativeModule and the module is missing, it throws during evaluation. Every file that imports it, directly or through other files, fails with it. If that chain reaches a route or the root layout, the app does not render. So native-only modules that are not guaranteed to exist are never imported at the top of a file. The codebase uses three guards.

Guard 1: dynamic import inside the function

Move the import into the function that needs it and wrap it in try/catch.
Callers treat null as “not available on this device” and return a safe default. Where it is used: The module’s types are still checked, because TypeScript resolves the dynamic import normally. Where the library’s own types are too broad, the file declares a narrow local interface for the functions it uses and casts through unknown, as the health providers do. No any is needed.

The component variant

A component cannot await. SystemNavigationBar loads its native component in an effect and renders nothing until it arrives:
This one sits in the root layout, so an unguarded import here would stop the whole app from rendering on a binary without the module.

Guard 2: probe before importing

A try/catch around await import() is not always enough. Project notes record a production incident: a guarded dynamic import of expo-image-manipulator still closed the app on binaries without the module when it ran from a tap handler. The explanation recorded there is that Metro reports a throwing module factory as a fatal error when no other module is mid-load, so the rejection never reaches the catch. During startup the load is nested inside another module’s evaluation and the catch works, which is why the pattern looked safe. The fix is to ask whether the native module exists before touching the JS package:
requireOptionalNativeModule returns null for a missing module instead of throwing. When it does, the package is never imported. scripts/test-shrink-image-guard.cjs pins this behaviour. Its first test is named “a binary without the manipulator uploads the original and never loads the module”. Use this guard for any module that is loaded from a press handler, an effect or a timer, and that might be absent on a live binary.

For your own modules

The local Android module is loaded the same way, at module scope, because the probe itself is safe:
Every exported function in cardioNotification.ts then checks native before using it.

Guard 3: Metro shims

Some packages are imported statically in many files, and guarding each import is not practical. expo-widgets and @expo/ui/swift-ui are imported by the widget and Live Activity layouts, for example. metro.config.js resolves these to stand-in files:
The resolver uses a shim when the platform is web, or when the environment variable EXPO_GO_SHIMS is 1 and the module is in the EXPO_GO_SHIMS list.
That starts a bundle where expo-widgets is inert, for running in Expo Go. The shim returns no-op widget and Live Activity handles and a null widgetsDirectory. Because the swap happens in Metro, TypeScript still type-checks against the real packages. A shim only has to export what the app imports. What each shim does:

Guard 4: platform files and platform checks

For UI that has no equivalent on a platform, Expo Router platform extensions keep the import out of that platform’s bundle:
  • (tabs)/_layout.tsx imports expo-router/unstable-native-tabs.
  • (tabs)/_layout.web.tsx and (tabs)/_layout.android.tsx do not.
For logic, check Platform.OS before using a platform-specific API. getHealthProvider() returns null on web, and promptForAppUpdate() returns early on anything that is not iOS or Android.

Version gates

Sometimes the module exists on every binary but a native fix does not. The health prompt is the example. It compares the installed version before showing a permission screen that older Android binaries cannot display:
isVersionAtLeast is in features/health/lib/runtime.ts and is reused by the app update prompt.

Choosing a guard

Things that defeat a guard

  • Re-exporting the guarded module from a barrel file with a static export * from.
  • Importing a type with a plain import in a setup where it is not erased. Use import type.
  • Calling into the module at the top level of a file, outside any function.
  • Adding a second, unguarded import of the same package in another file.
Before shipping JS that touches a new native module over the air, search for every import of that package and confirm each one is guarded. See OTA updates.