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 intry/catch.
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 cannotawait. SystemNavigationBar loads its native component in an effect and renders nothing until it arrives:
Guard 2: probe before importing
Atry/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: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:
web, or when the environment variable EXPO_GO_SHIMS is 1 and the module is in the EXPO_GO_SHIMS list.
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.tsximportsexpo-router/unstable-native-tabs.(tabs)/_layout.web.tsxand(tabs)/_layout.android.tsxdo not.
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
importin a setup where it is not erased. Useimport 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.