On This Page
1. The Error
You upgrade to React Native 0.87 (or run tsc --noEmit for the first time after the upgrade), and a file that used to compile cleanly now fails:
src/native/NativeToggleModule.ts:3:39 - error TS2307: Cannot find module 'react-native/Libraries/Types/CodegenTypes' or its corresponding type declarations.
3 import type {Int32, WithDefault} from 'react-native/Libraries/Types/CodegenTypes';
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Found 1 error in src/native/NativeToggleModule.ts:3In VS Code you'll see the same thing as a red squiggle on the import path, hovering to:
Cannot find module 'react-native/Libraries/Types/CodegenTypes' or its corresponding type declarations.ts(2307)A close cousin shows up when the deep import used to resolve but the specific export you were pulling off it has been renamed or removed:
error TS2305: Module '"react-native"' has no exported member 'NativeMethods'.The important thing to notice: your app still runs. Metro bundles the JS fine, Fast Refresh still works, the simulator shows your screen. This is a type-checker-only failure — tsc --noEmit, your editor, and (if you wired it up) your CI type-check step are the only things that see it. That's the tell that you're looking at the Strict TypeScript API becoming the default, not a Metro or runtime problem.
Where this applies: React Native 0.87+ on any setup — bare React Native CLI or Expo — once your tsconfig.json resolves through @react-native/typescript-config 0.87+ (which it does by default after the upgrade). Bare RN CLI apps hit this the moment they bump react-native to 0.87. Expo apps hit it once their SDK adopts RN 0.87 — the current stable Expo SDK (57) still ships RN 0.86, where this is opt-in, not default (more in the version matrix below). If you opted in to Strict TypeScript API early (0.80–0.86, via a customConditions entry), you've already seen this error before 0.87 forced it on everyone.
2. How to Reproduce It
Minimal bare RN CLI repro, on 0.87.1:
npx @react-native-community/cli@latest init StrictRepro --version 0.87.1
cd StrictRepropackage.json (relevant slice — this is what init gives you on 0.87.1, unmodified):
{
"dependencies": {
"react": "19.2.3",
"react-native": "0.87.1"
},
"devDependencies": {
"@react-native/typescript-config": "0.87.1",
"typescript": "^5.8.3"
}
}tsconfig.json (also unmodified — this is the part that matters):
{
"extends": "@react-native/typescript-config"
}As of 0.87, that base config already applies the "react-native" custom condition — you don't add anything to get strict behavior, you have to add something to get out of it (Section 6).
Add a native module spec, src/native/NativeToggleModule.ts — this is realistic, not contrived: importing Int32/WithDefault from the internal Codegen types path is exactly what the official Codegen native-module docs told you to do for years:
import type {Int32, WithDefault} from 'react-native/Libraries/Types/CodegenTypes';
import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';
export interface Spec extends TurboModule {
setEnabled(enabled: WithDefault<boolean, true>): void;
getCount(): Int32;
}
export default TurboModuleRegistry.getEnforcing<Spec>('ToggleModule');Run the type checker:
npx tsc --noEmitYou'll get the TS2307 error from Section 1. Run the app instead (npx react-native run-ios / run-android) and it launches without complaint — Metro doesn't consult tsconfig.json customConditions, so the runtime path is untouched.
Triggers: any .ts/.tsx file — yours or a dependency's shipped source — that imports from react-native/Libraries/* (or react-native/src/private/*) under a tsconfig.json that extends @react-native/typescript-config 0.87+ without the legacy opt-out. It fires in the editor, in tsc --noEmit, and in any CI step that runs a type check — including jest configs that type-check, and Metro's own optional type-checking middleware if enabled. It does not fire from Metro's bundler resolution or from Babel, which is why teams that don't gate CI on tsc --noEmit sometimes ship this and only notice when a contributor's editor lights up red.
3. Version Behavior Matrix
| Version | Strict TypeScript API | Deep-import behavior |
|---|---|---|
| 0.79 | Doesn't exist | Deep imports resolve normally against hand-maintained types, no warning |
| 0.80 (Jun 12, 2025) | Introduced, opt-in via customConditions: ["react-native-strict-api"] in tsconfig.json | Outside opt-in: @react-native/eslint-plugin's no-deep-imports rule and a @react-native/babel-preset dev-mode console.warn flag deep imports as deprecated; not a type error |
| 0.81 – 0.86 | Still opt-in (condition name settled to customConditions: ["react-native"]) | Same soft warnings by default; hard TS2307 only if you opted in |
| 0.87 (Aug 11, 2026) | Default for everyone | Deep imports into react-native/Libraries/* are a hard TS2307/TS2305. Escape hatch: add "react-native-legacy-deep-imports" alongside "react-native" in customConditions — documented as available through 0.88 only, after which the legacy type bundle is removed entirely |
This one is version-neutral in the sense that the underlying deep-import pattern has been discouraged since 0.80 — 0.87 just changes the enforcement from a warning to a build-breaking type error. Two other 0.87 changes compound with this if you're doing an upgrade in one jump: useColorScheme() now returns ColorSchemeName | null instead of ever returning 'unspecified', and InteractionManager is removed (use requestIdleCallback) — both are separate breakages but show up in the same tsc --noEmit run, so don't assume every red line is a deep-import problem.
Framework layer: Expo SDK 57 (current stable, released June 30, 2026) ships React Native 0.86 — Strict TypeScript API is still opt-in there, so most Expo apps won't see this error yet unless they explicitly added the customConditions opt-in. Expect it to become default the moment Expo ships an SDK built on RN 0.87. If you're on Expo and want to get ahead of it, opt in manually now (same tsconfig.json change) and fix the fallout on your schedule instead of the SDK's.
4. Why It Happens — Surface Level
react-native's package.json now publishes a scoped exports map. Before Strict TypeScript API, that map (or the absence of one) let a wildcard ./* condition expose the type declarations for every file under Libraries/, so react-native/Libraries/Modal/Modal resolved to a real .d.ts and TypeScript was happy. Under the "react-native" custom condition — now applied by default via @react-native/typescript-config 0.87 — that wildcard fallback is gone. Only the paths React Native's own root index.js actually re-exports are reachable. A deep path like Libraries/Types/CodegenTypes simply has no matching export condition anymore, so tsc reports exactly what it would for any nonexistent module: TS2307.
5. Why It Happens — Under the Hood
TypeScript's module resolution (with moduleResolution: "bundler" or "node16"/"nodenext", which @react-native/typescript-config sets) walks a package's exports field and picks a branch based on which conditions are active — import, require, types, and any custom ones you list in compilerOptions.customConditions. Before 0.87, resolving a subpath like ./Libraries/Types/CodegenTypes against react-native's exports map fell through to a generic types condition backed by a ./* wildcard, which pointed at the whole Libraries/ tree — so it worked whether or not you opted in to anything. This was actually flagged as a real bug during the opt-in period: react/react-native#53565 showed that even with the strict condition active, the types condition's wildcard fallback still let deep imports through, because the resolver matched a permissive branch before checking the stricter one. The fix tightened the map so a deep path under the "react-native" condition resolves to an explicit null — which TypeScript treats as "this subpath is blocked," not "fall back to something else."
That's the mechanical half. The other half is where the type declarations themselves changed. React Native used to ship a single hand-maintained index.d.ts (thousands of lines, edited by hand, chronically out of sync with the actual Flow-typed source — a change to a prop in a .js component didn't automatically update the .d.ts). Since 0.80, the public types are generated directly from that Flow source instead, which is also why ref types changed shape: useRef<View>(null) typed the ref as the component, but Fabric's host components expose their imperative surface (measure, setNativeProps, focus) through a generated instance interface, not the component class. That's now ViewInstance (and TextInputInstance, ScrollViewInstance, and 20-odd others), replacing the old NativeMethods/NativeMethodsMixin types outright — which is why you'll sometimes see TS2305: has no exported member 'NativeMethods' sitting right next to your TS2307s after an upgrade.
One distinction worth being precise about: this article's error is a type-resolution failure, not a runtime module-resolution failure. If a file does a value deep import — import {Alert} from 'react-native/Libraries/Alert/Alert' without import type — Metro still resolves that against the real filesystem via its Haste/Node resolver, which doesn't consult tsconfig.json customConditions at all. That import still bundles and runs; it just also warns at dev-server startup via the no-deep-imports ESLint rule and the Babel-injected console.warn. React Native hasn't announced a runtime-enforcement date for that path — only the type path was hard-cut in 0.87.
6. The Fix
Correct fix — migrate to the public API. For the Codegen spec file from Section 2:
- import type {Int32, WithDefault} from 'react-native/Libraries/Types/CodegenTypes';
+ import {CodegenTypes} from 'react-native';
import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';
export interface Spec extends TurboModule {
- setEnabled(enabled: WithDefault<boolean, true>): void;
- getCount(): Int32;
+ setEnabled(enabled: CodegenTypes.WithDefault<boolean, true>): void;
+ getCount(): CodegenTypes.Int32;
}
export default TurboModuleRegistry.getEnforcing<Spec>('ToggleModule');For a ref that used the removed NativeMethods/component-as-instance pattern:
- import {View, TextInput} from 'react-native';
+ import {View, TextInput} from 'react-native';
+ import type {ViewInstance, TextInputInstance} from 'react-native';
import {useRef} from 'react';
function Field() {
- const viewRef = useRef<View>(null);
- const inputRef = useRef<TextInput>(null);
+ const viewRef = useRef<ViewInstance>(null);
+ const inputRef = useRef<TextInputInstance>(null);
return (
<View ref={viewRef}>
<TextInput ref={inputRef} />
</View>
);
}*Instance types work transparently with the Animated.* wrapper components too, so you don't need a separate type for Animated.View.
Automated migration. React Native ships a codemod for exactly this move:
npx skills add react-native-community/skills --skill migrate-to-strict-apiRun it before hand-editing anything — on a codebase with more than a handful of deep imports it'll get you 90% of the way and leave the ambiguous cases for you.
Temporary unblock — not a fix. If you need the upgrade to land today and the migration to happen next sprint, opt back into the legacy type bundle:
{
"extends": "@react-native/typescript-config",
"compilerOptions": {
- "customConditions": ["react-native"]
+ "customConditions": ["react-native", "react-native-legacy-deep-imports"]
}
}Be clear with your team about what this buys you: it's documented as available only through 0.88, after which the legacy .d.ts bundle is deleted from the package entirely and this stops working no matter what your tsconfig.json says. Put a ticket on the board now, not when 0.89 breaks the build.
If the error is coming from a dependency, not your own code — some library's shipped .d.ts still deep-imports internally — first check skipLibCheck in your tsconfig.json. @react-native/typescript-config sets it true by default specifically so a third-party package's outdated types don't block your build; don't turn it off. If you've turned it off and are now paying for it, that's your fix. Otherwise, update the dependency (this exact class of break already shipped fixes for real packages — react-native-safe-area-context fixed it in 5.8.1) or pin/patch it as a stopgap.
7. Best Practices & The Better Design
Treat react-native's root export as the only contract your code depends on — the same discipline you'd apply to any other package's public API. Concretely: never write from 'react-native/Libraries/...' in new code, even under the legacy opt-out; use the CodegenTypes namespace for spec files instead of the old flat Int32/WithDefault imports; use the generated *Instance types for anything you useRef against a host component. This isn't just about dodging a type error — it's what lets React Native refactor its internals (which it does constantly) without your app being collateral damage on the next minor bump.
import {useRef} from 'react';
import {
View,
TextInput,
CodegenTypes,
type ViewInstance,
type TextInputInstance,
} from 'react-native';
export function SearchField() {
const containerRef = useRef<ViewInstance>(null);
const inputRef = useRef<TextInputInstance>(null);
return (
<View ref={containerRef}>
<TextInput ref={inputRef} placeholder="Search" />
</View>
);
}
export type SearchFieldFlag = CodegenTypes.WithDefault<boolean, true>;This compiles clean on 0.87.1 with the default (strict) tsconfig.json — no opt-out, no deep import, nothing to revisit when 0.88 removes the bridge.
8. How to Prevent It Long-Term
Add @react-native/eslint-config (it pulls in the no-deep-imports rule) so violations show up as lint errors in your editor and pre-commit hook, not as a surprise on upgrade day. Gate CI on npx tsc --noEmit as its own step, separate from the Metro build — this is the only thing that would have caught the repro in Section 2 before it shipped, since a debug or release build succeeds regardless. Run npx react-native doctor and, if you're on Expo, npx expo install --check / npx expo-doctor as part of every upgrade ticket, and work through the official Upgrade Helper (react-native-community.github.io/upgrade-helper) rather than bumping package.json freehand. Keep skipLibCheck: true so your own type-check signal isn't drowned out by node_modules noise, but do periodically run with it off in a throwaway branch to see what's accumulating in your dependency tree. Finally, track the opt-out's expiry explicitly: if you added react-native-legacy-deep-imports as a stopgap, file the follow-up ticket the same day, with "before 0.89" as the deadline, not "someday."
9. Key Takeaways
TS2307: Cannot find module 'react-native/Libraries/...'andTS2305: Module '"react-native"' has no exported member '...'are compile-time-only — they mean Strict TypeScript API is now enforcing react-native's public API boundary, and your app still runs fine at runtime.- This became the default in React Native 0.87 (Aug 11, 2026); it's been available as an opt-in since 0.80. Expo apps won't see it forced on until their SDK moves past RN 0.86.
- The real fix is migrating deep imports to the root
react-nativeexport —CodegenTypesfor spec files,*Instancetypes (ViewInstance,TextInputInstance, etc.) for refs — and the official codemod (migrate-to-strict-api) does most of it for you. - The
"react-native-legacy-deep-imports"customConditionsentry is a bridge, not a fix — it's removed after 0.88, so treat it as a dated loan, not a setting. - Gate CI on
tsc --noEmitseparately from your build step; it's the only thing in this pipeline that actually checks for this class of error before a teammate's editor does.
Related: if you're mid-upgrade and also seeing Cannot find module 'react-native/jest-preset', that's the 0.85 Jest preset extraction, not this error — different fix, same "upgrade broke my tooling, not my app" shape. If refs are what's breaking for you specifically, see the New Architecture / Fabric host-instance changes for why measure/setNativeProps moved off the component class in the first place.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.