Skip to Content
📖 Guide Documents📦 SetupMetro (React Native, Expo)

Metro (React Native)

React Native and Expo bundle with Metro, which transpiles each file with Babel. Babel strips TypeScript types and never runs TypeScript transformers, so neither the ttsc CLI nor @ttsc/unplugin can reach the build. @ttsc/metro is the adapter for that world: a Metro custom transformer that runs the ttsc plugin pass (typia, nestia, …) on each TypeScript file, then hands the result to your existing Expo or React-Native Babel transformer.

Install

npm install -D ttsc typescript @ttsc/metro

Wrap your Metro config

For Expo:

// metro.config.js const { getDefaultConfig } = require("expo/metro-config"); const { withTtsc } = require("@ttsc/metro"); module.exports = withTtsc(getDefaultConfig(__dirname));

For bare React Native, the only change is where the default config comes from:

// metro.config.js const { getDefaultConfig } = require("@react-native/metro-config"); const { withTtsc } = require("@ttsc/metro"); module.exports = withTtsc(getDefaultConfig(__dirname));

withTtsc sets transformer.babelTransformerPath and leaves the rest of your config untouched. It auto-detects the upstream Babel transformer to delegate to: @expo/metro-config/babel-transformer for Expo, then @react-native/metro-babel-transformer, then the legacy metro-react-native-babel-transformer. Those candidates, and a package name passed as upstreamTransformer, resolve from your project (projectRoot), so a transformer only the app installed is found even when the adapter itself lives outside the app’s node_modules, as it does under pnpm or a linked workspace.

If your config already set transformer.babelTransformerPath, that transformer is chained rather than replaced: the ttsc pass runs first and then delegates to it, so wrapping a working config keeps what it configured. This is what makes react-native-svg-transformer, whose entire installation is that one assignment, keep working after you adopt @ttsc/metro. Pass upstreamTransformer explicitly to override both that and auto-detection.

The value is resolved from your projectRoot, exactly as Metro resolves it, so a relative "./metro-svg.cjs" and a bare "react-native-svg-transformer" both mean what they mean in your project rather than inside this package. A path that names @ttsc/metro’s own transformer is never chained in any spelling, including a second copy installed elsewhere in your tree, because delegating this transformer into itself would recurse on every file.

Options

By default @ttsc/metro finds the nearest tsconfig.json from the file being transformed and runs the plugins configured there. If that config does not include the file, it follows references in declaration order, depth-first, and selects the first project that does. This supports solution configs with files: []. If no project includes the file, it retains the nearest config and reports the missing output. An explicit project keeps its own selection.

Options are the second argument and mirror @ttsc/unplugin, plus a few Metro-specific knobs:

module.exports = withTtsc(getDefaultConfig(__dirname), { project: "tsconfig.build.json", plugins: [{ transform: "typia/lib/transform" }], exclude: ["__tests__"], });
  • project: the tsconfig.json the transformer should read, resolved from process.cwd().
  • precompile: source entry paths whose projects start compiling together on the worker’s first transform. Paths resolve from process.cwd() and retain the user’s project-selection options; a project discovered for another delivered module does not reroute them. See Unplugin options for startup effects and failure delivery.
  • compilerOptions: a temporary overlay layered on the selected project config.
  • plugins: an explicit plugin list override, or false to disable project plugins.
  • upstreamTransformer: an explicit module path for the Babel transformer to delegate to, when neither the config’s own transformer nor auto-detection is what you want.
  • include / exclude: substring patterns against the project-relative path, selecting which files run through the ttsc pass. Only .ts/.tsx/.cts/.mts are candidates; declaration and JavaScript files always pass straight through.

Options travel from the Metro config process to its worker processes through an environment variable, so they must stay JSON-serialisable: substring patterns, not RegExp.

Source maps

Metro builds each module’s source map from the AST the Babel transformer returns, and that transformer parsed the ttsc output, not your file. The ttsc pass returns a map back to your text, and @ttsc/metro moves the AST’s locations through it before Metro sees them, so stack traces, breakpoints, and the red box point at the lines you wrote. Code a plugin generated has no line of yours, so it takes the location of the code around it.

Files outside the program

Metro resolves its own module graph, and that graph is not the set of files your tsconfig describes. A file Metro delivers that the compiled program does not contain is passed to the upstream transformer untransformed, and reported naming the file and the tsconfig it is missing from, once per file per compile in each Metro worker, since a Metro worker has no build boundary to reset the report at:

ttsc: /app/scripts/tool.ts is not part of the program described by /app/tsconfig.json, so it was left untransformed. Add it to that project's "include" if ttsc plugins should apply to it.

This is not a build error. The file is simply not this project’s to transform, and the usual cause is a Metro graph reaching past the tsconfig’s include, such as a source beside src rather than inside it or one in a sibling workspace folder. Add it to that project’s include if ttsc plugins should apply to it.

A skipped file retains its plugin-driven calls, which may fail at runtime. Declaration and JavaScript files are filtered before this check.

The same pass-through behavior applies to @ttsc/unplugin.

Cache invalidation

The upstream transformer’s cache key contributes to the adapter’s key. If loading the upstream or computing its key fails, this run receives a random key so it cannot reuse output whose upstream state is unknown. An upstream without the optional getCacheKey callback contributes no extra key.

Compiler overlays and plugin options retain their transmitted JSON property order in the key. Reordering a paths map can change wildcard precedence, and a plugin may interpret its own option order, so those records do not share cached output solely because their key/value pairs match.

The cache fingerprint includes project files, effective configs, resolution inputs, plugin dependencies, and plugin Go sources and build environment. Nested and linked projects are tracked under their own configs. Changes to those inputs invalidate the next run, including newly created resolution targets and retargeted symlinks or Windows junctions. Restarting a bundle or dev server picks up these changes without --reset-cache.

The recorded input set follows what the transform core derives per file, so a plugin that declares its reported dependency list complete for a file narrows what that file contributes to the fingerprint. Files under the project root are walked regardless of any declaration.

Metro uses one transformer key per run, so a fingerprint change re-transforms every file on the next run. During development and recovery:

  • Within a running dev server, Metro re-transforms only files its watcher reports changed, so editing a type in file B refreshes a dependent file A on A’s next transform. Save A, or restart the dev server; no --reset-cache is needed in either case.
  • Files a plugin declares volatile depend on non-file inputs no fingerprint can represent; while a volatile declaration is recorded, cross-run cache reuse is disabled entirely.
  • Snapshot persistence failures disable reusable keys until pending observations can be saved. A worker fails the transform if it cannot persist the inputs required by an already reusable key.

Caveats (v1)

  • Cost model. The transform core type-checks the whole project. Metro runs a multi-process worker pool, and the workers share each compile through the session withTtsc opens: the first worker to need a project state compiles it, and the others adopt it once they have proven that state themselves. A cold build and each edit therefore compile the project once, not once per worker. A resident incremental compiler is the next step, tracked in #255 .
  • Type errors fail the build. The pass type-checks, and a project type error surfaces as a Metro build error, matching every other ttsc integration.
Last updated on