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
npm install -D ttsc typescript @ttsc/metroWrap 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.
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, the standard ttsc model. If that is what you want, wrapping the default config is the whole setup.
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: thetsconfig.jsonthe transformer should read, resolved fromprocess.cwd().compilerOptions: a temporary overlay layered on the selected project config.plugins: an explicit plugin list override, orfalseto 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 thettscpass. Only.ts/.tsx/.cts/.mtsare 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.
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.
The report matters because passing through is not the same as leaving alone: a file that skips the ttsc pass keeps whatever plugin-driven syntax it carries, which fails at runtime rather than at build time. Declaration and JavaScript files never reach the pass at all, so they are not reported; they are filtered before it, as include / exclude above describes.
This answer belongs to the shared transform core rather than to Metro, so every @ttsc/unplugin adapter gives the identical one.
Cache invalidation
The exact filesystem inputs consulted by TypeScript-Go resolution are recorded as fingerprint inputs, with importer-owned probes following their reference closure and automatic type discovery remaining universal. A missing input remains recorded even when it lies under the project root, so creating it changes the next Metro cache key even though the first project walk could not hash a path that did not yet exist. Existing unsuccessful probes are already part of the ordinary project walk or the recorded input set.
Metro keys its transform cache on each file’s own content plus one static transformer key evaluated once per run, and its babel-transformer contract offers no per-file dependency registration. A ttsc transform can depend on a type declared in another file, so @ttsc/metro folds a project fingerprint into the static key: every regular file reached by the project walk, every effective config source, and transform inputs recorded under node_modules/.cache/ttsc-metro. In a workspace with nested or directory-linked tsconfig.json projects, each lexical subtree is walked under the nearest config that transformed files use; the project-map traversal follows symlinks and Windows junctions while a physical ancestor set cuts cycles without collapsing independent aliases. The project map itself is hashed so adding or removing a nested or linked project config also changes the key. Worker discovery retains the exact candidate predicates that selected its project instead of reconstructing them after selection. A module passed through because its Program has no output still records those candidates and the universal config inputs that can later admit it. withTtsc gives each run a private identity, getCacheKey records the exact file state and static coverage that identity hashed, and workers compare every derived input’s compiler-generation hash, realpath, or resolution predicate with that baseline. Inputs outside proven static coverage stay in one batched worker snapshot. A missing input, an out-of-root watchFolders project, a symlink or Windows junction transition, an extends change, or any A-to-B-to-A change between static keying and transformation taints the observation and rotates the next snapshot epoch, so it cannot collide with the earlier A key. Editing any fingerprinted input re-keys the next run, so metro bundle and dev-server starts pick up cross-file type changes, tsconfig edits, and plugin configuration 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.
The granularity is project-level because Metro’s contract admits nothing finer: one key per run means any fingerprinted change re-transforms every file on the next run. Two behaviors sit outside the mechanism’s reach:
- 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-cacheis needed in either case. - Files a plugin declares
volatiledepend on non-file inputs no fingerprint can represent; while a volatile declaration is recorded, cross-run cache reuse is disabled entirely. - If snapshot persistence fails, a recovery document beside
node_modules/.cache/ttsc-metromakes every later key non-reusable until the pending observations are compacted under a fresh epoch. If preparation cannot persist even that document, its run token forces a nonce across bundle and process boundaries. A worker whose run token authorized a reusable key fails the transform if neither location accepts its observation, even when the old main snapshot is temporarily unreadable.
Caveats (v1)
- Cost model. The transform core type-checks the whole project and caches the result per process. Metro runs a multi-process worker pool, so the project compiles once per worker, on that worker’s first file. A resident incremental compiler shared across workers is the planned fix, 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
ttscintegration.