In a React Native CRM app, an @components alias passes tsc but Metro says 'Unable to resolve module'; why, and what fixes it?
answer
- tsc never rewrites import paths
- Babel must rewrite them for Metro
- babel-plugin-module-resolver
- same alias in two files
- restart Metro with --reset-cache
basics
~20 stsconfig paths only teach tsc where to find types; nothing rewrites the import for Metro. Add the same alias to babel.config.js with babel-plugin-module-resolver, then restart Metro with --reset-cache, since its transform cache does not track babel.config.js.
solid answer
~30 sA `paths` entry in `tsconfig.json` is resolution information for the type checker only; in React Native `tsc` runs with `noEmit`, so it rewrites nothing. Metro bundles with Babel and resolves `@components/ContactCard` as a package name, finds none, and fails with "Unable to resolve module". The React Native docs' fix is to declare the alias in both places: `paths` in `tsconfig.json`, and `babel-plugin-module-resolver` in `babel.config.js`, which rewrites the import to a relative path while bundling. Keep the two mappings identical, keep `module:@react-native/babel-preset` as the preset, and restart Metro with `--reset-cache`, because the transformer's cache key does not include your `babel.config.js`.
code
javascript · 15 lines// babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
[
'module-resolver',
{
root: ['./src'],
alias: {
'@components': './src/components',
},
},
],
],
};go deeper
Recall that path aliases in React Native need both tsconfig paths and babel-plugin-module-resolver.
Explain why tsc resolves aliases only for types, how the Babel plugin rewrites imports for Metro, and why the cache must be reset.
Diagnose alias mismatches that type-check against one file while bundling another, and keep Jest, Babel and tsc on one mapping.
Weigh aliases against plain relative imports or workspace packages, given the cost of keeping two resolvers in sync.
## The symptom A CRM app's team wants shorter imports. Someone adds this to `tsconfig.json`: ```json { "extends": "@react-native/typescript-config", "compilerOptions": { "paths": { "@components/*": ["./src/components/*"] } } } ``` and changes `import ContactCard from '../../../components/ContactCard'` to `import ContactCard from '@components/ContactCard'`. The editor is happy, `npx tsc` passes, and the app fails to load: Metro reports **Unable to resolve module @components/ContactCard**. ## Why it type-checks A **path alias** in `tsconfig.json` tells the TypeScript compiler where to look when it sees an import that matches the pattern. It affects **type resolution only**. TypeScript does not rewrite import specifiers in its output, and in a React Native app it produces no output anyway: the base config sets `noEmit: true`. ## Why it fails at runtime **Metro** builds the bundle. It transforms each file with **Babel** and then resolves every import itself. Neither Babel's default React Native preset nor Metro reads `tsconfig.json`. To Metro, `@components/ContactCard` looks like a scoped npm package named `@components`, which does not exist in `node_modules`, so resolution fails. The general rule: **every resolution trick has to be taught to both tools**, because they resolve independently. ## The documented fix The React Native TypeScript docs describe three steps: 1. Declare the alias in `tsconfig.json` `paths`, as above. 2. Install **`babel-plugin-module-resolver`** as a dev dependency. 3. Configure it in `babel.config.js` with the same mapping. The plugin rewrites `@components/ContactCard` into a relative path during Babel's transform, before Metro resolves it, so Metro sees an ordinary relative import. One trap in the docs: their example still lists the old `module:metro-react-native-babel-preset`. Current apps use **`module:@react-native/babel-preset`**; keep whatever preset your template ships and only add the plugin. ## Why the fix seems not to work After editing `babel.config.js`, many teams still see the same error. Metro caches transformed files, and React Native's Babel transformer builds its cache key from the preset and the transformer itself, **not** from your project's Babel config. Old transform results stay valid in Metro's eyes. Restart the dev server with a clean cache: - `npx react-native start --reset-cache`, or the equivalent `npm start -- --reset-cache`. ## Keeping the two mappings honest | Check | Why | |---|---| | Same alias names in both files | a typo in one gives type-only or runtime-only failures | | Same target folders | otherwise types come from one file and code from another | | Jest uses the same Babel config | tests then resolve aliases the way the app does | | One owner for both files | alias changes land in one reviewed commit | Two further points: - An alias that points at the wrong folder in Babel but the right one in `tsconfig.json` is the worst case: types check against one file while the bundle loads another. - Metro can also be taught aliases through its own resolver configuration, which is a Metro configuration topic; the Babel plugin is the route the React Native docs document. ## The lesson In React Native, `tsconfig.json` describes the program to the type checker, while `babel.config.js` and Metro describe it to the bundle. A green `tsc` run proves nothing about whether Metro can resolve an import. ## Diagnosing which half is broken When an alias misbehaves, test each resolver separately: - **`tsc` fails, the app runs**: the Babel mapping exists but `paths` is missing or wrong; the editor also loses types for the import. - **`tsc` passes, Metro fails**: the classic case above; the Babel plugin is missing, misconfigured, or Metro is serving stale transforms. - **Both pass, wrong behaviour**: the two mappings point at different folders; compare them line by line. - **Tests fail, app runs**: Jest is not picking up the same Babel configuration or has its own module mapping that disagrees. Fixing the right half first saves the usual cycle of editing both files at random and restarting Metro between attempts.
- In React Native, why does editing babel.config.js sometimes seem to have no effect until Metro is restarted with --reset-cache?React Native's Babel transformer computes Metro's cache key from `@react-native/babel-preset` and the transformer code, not from the project's `babel.config.js`. Previously transformed files are therefore reused. Restarting with `--reset-cache` discards them so every file is transformed with the new plugins.
- Could you drop the tsconfig paths entry and keep only the Babel plugin in a React Native app?The bundle would work, but `tsc` and the editor would report the aliased imports as unresolvable modules and lose their types. Both halves are needed: Babel for the runtime, `paths` for the type checker.
The tsconfig paths entry is a street map handed to the inspector who checks your delivery list, while Metro is the driver carrying a different map. The inspector signs off on every address; the driver still gets lost until the new street is drawn on the driver's map too.
saying these in an interview costs you the question
- tsconfig paths rewrite imports in the bundled JavaScript
- Metro reads tsconfig.json to resolve aliases
- If tsc passes, Metro will resolve every import
- Babel config changes apply as soon as you save the file
- The alias only needs to be declared in babel.config.js