Unplugin (Bundlers)
When a bundler owns your build, the ttsc CLI never runs, and neither would your plugins. @ttsc/unplugin runs the same plugin pass from inside the bundler. It reads the same tsconfig.json and the same lint.config.ts, so there is no second configuration to drift out of date.
Adapters ship for Vite, Rollup, Rolldown, esbuild, webpack, Rspack, Next.js, Turbopack, Farm, and Bun. The import path is always @ttsc/unplugin/<bundler>. React Native and Expo bundle with Metro, which is not an unplugin target; that is a separate package with its own page, Metro (React Native).
Install
npm
npm install -D ttsc typescript @ttsc/unpluginThen register the adapter that matches your bundler.
The package publishes matching ESM and CommonJS runtime and type branches. The default imports below type-check under both moduleResolution: "nodenext" and moduleResolution: "bundler"; CommonJS require() continues to expose each adapter as .default.
Keep the two versions together
@ttsc/unplugin requires the matching ttsc minor version. Upgrade both together; updating only one can fail peer-dependency resolution:
npm error While resolving: @ttsc/unplugin@0.25.0
npm error Found: ttsc@0.26.0Group the packages in automated dependency updates:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
groups:
ttsc:
patterns:
- "ttsc"
- "@ttsc/*"This creates one pull request for the installed ttsc packages. In Renovate, use a packageRules entry with groupName over the same patterns.
Pin both to the same version in package.json.
Vite
// vite.config.ts
import ttsc from "@ttsc/unplugin/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [ttsc()],
});A query that makes the host generate a module around a source file is left to the host: ?raw, ?url, ?inline, ?no-inline, ?worker, and ?sharedworker, on Vite and on webpack or Rspack rules that use the same names. import text from "./schema.ts?raw" therefore yields the file’s own text, and importing the file both ways compiles the project once. Every other query still denotes the file’s program and is transformed, including cache busting such as ?t=, the ?worker_file entry, and route chunks such as React Router’s ?route-chunk=.
ttsc compiles each file from disk. A module that reaches it with different text, because a plugin ordered before ttsc rewrote it or the file changed after the host read it, is served from the file’s compile. ttsc reports that once per file and never recompiles the project for it, so a rewrite made before ttsc runs is visible rather than silently lost. Order such plugins after ttsc, which runs with enforce: "pre".
Vite’s dependency scanner reads transformed imports, including those introduced by plugins in linked workspace sources, and resolves them from their importing file. This lets the initial scan discover dependencies that would otherwise appear when the transformed module is served and trigger another optimization. Your optimizeDeps entries, exclusions and optimizer plugins remain in effect; noDiscovery: true keeps automatic scanning disabled.
Rollup
// rollup.config.ts
import ttsc from "@ttsc/unplugin/rollup";
import esbuild from "rollup-plugin-esbuild";
export default {
input: "src/index.ts",
output: { dir: "dist", format: "esm" },
plugins: [ttsc(), esbuild()],
};The adapter emits transformed TypeScript, not JavaScript, and plain Rollup does not compile TypeScript itself, so an esbuild- or swc-based plugin follows ttsc(); without it the build fails parsing type syntax. Vite, Rolldown, esbuild, Farm, and Bun strip types themselves. webpack 5.109 or later can do so under the conditions below; Rspack needs a TypeScript loader.
Rolldown
// rolldown.config.ts
import ttsc from "@ttsc/unplugin/rolldown";
export default {
input: "src/index.ts",
output: { dir: "dist", format: "esm" },
plugins: [ttsc()],
};Same shape as Rollup, but Rolldown ships its own TypeScript transform, so the downstream compile step Rollup needs does not apply here.
esbuild
import { build } from "esbuild";
import ttsc from "@ttsc/unplugin/esbuild";
await build({
entryPoints: ["src/index.ts"],
outdir: "dist",
bundle: true,
plugins: [ttsc()],
});webpack
// webpack.config.mjs
import ttsc from "@ttsc/unplugin/webpack";
export default {
entry: "./src/index.ts",
plugins: [ttsc()],
};Starting with webpack 5.109, experiments.typescript defaults to "auto": built-in TypeScript support turns on when Node supplies module.stripTypeScriptTypes and no rule handles .ts, .mts, or .cts. When built-in support is unavailable or disabled, add a TypeScript loader after the adapter, for example module: { rules: [{ test: /\.[cm]?tsx?$/, loader: "esbuild-loader" }] }.
Rspack
import ttsc from "@ttsc/unplugin/rspack";
export default {
entry: "./src/index.ts",
module: {
rules: [
{
test: /\.[cm]?tsx?$/,
loader: "builtin:swc-loader",
options: { jsc: { parser: { syntax: "typescript" } } },
type: "javascript/auto",
},
],
},
plugins: [ttsc()],
};Next.js
// next.config.mjs
import withTtsc from "@ttsc/unplugin/next";
export default withTtsc({
/* your Next config */
});withTtsc covers both of Next.js’s bundlers: it injects the webpack plugin and wires the Turbopack loader rules, with the options you pass reaching both. Your own webpack hook and turbopack block are preserved, and a rule you already wired for this loader by hand is left alone rather than registered twice.
Duplicate detection recognizes these exact glob forms: *.ext, **/*.ext, and {**/,}*.ext for ts, tsx, mts, and cts, plus *.{...}, {*.ext,...}, **/*.{...}, **/{*.ext,...}, and **/**/*.{...} for canonical combinations of two or more of those extensions. Other spellings retain your rule and receive the wrapper’s rule too, which can transform a module twice. Prefer letting withTtsc register the loader.
Turbopack
Turbopack has no JS plugin API, but it runs webpack loaders through turbopack.rules, and a ttsc transform is loader-shaped: TypeScript source in, transformed source out. In a Next.js project withTtsc wires this for you; do it by hand when Turbopack runs outside Next, or when you want the rules under your own control. Reference the standalone loader by module name (do not call it):
// next.config.mjs
export default {
turbopack: {
rules: {
"*.ts": { loaders: ["@ttsc/unplugin/turbopack"] },
"*.tsx": { loaders: ["@ttsc/unplugin/turbopack"] },
"*.mts": { loaders: ["@ttsc/unplugin/turbopack"] },
"*.cts": { loaders: ["@ttsc/unplugin/turbopack"] },
},
},
};Options go through the rule’s options object: { loader: "@ttsc/unplugin/turbopack", options: { project: "tsconfig.build.json" } }. The loader applies the same filter the unplugin adapters apply, so a rule glob wider than the four above still passes declaration files, node_modules paths, non-TypeScript sources, and virtual ids through untouched.
withTtsc shares project compilations across Turbopack workers, including compiler diagnostics. Workers validate project and external inputs before reusing a result. Recent compilations are stored in the user’s system temporary directory and can be reused after a restart; package upgrades invalidate them. With hand-written turbopack.rules and no withTtsc, each worker compiles separately.
During development, compiler failures appear as issues and remain until an input changes. Adapter failures or inputs that keep changing fail the loader request and are retried on the next request. A crashed worker does not block other workers.
The loader registers one project record under Turbopack’s .ttsc directory. The adapter updates it when compiler inputs change, including workspace packages outside Turbopack’s root. You do not need to widen turbopack.root for these transform dependencies.
Farm
import ttsc from "@ttsc/unplugin/farm";
import { defineConfig } from "@farmfe/core";
export default defineConfig({ plugins: [ttsc()] });Bun
import ttsc from "@ttsc/unplugin/bun";
await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
plugins: [ttsc()],
});Under Bun.build, the adapter yields to the next loader for declarations, node_modules, source that ttsc leaves unchanged, and entries supplied through Bun.build({ files }). In-memory entries remain with Bun because ttsc transforms filesystem-backed project inputs. Bun’s runtime onLoad contract does not accept an undefined result, so Bun.plugin() returns excluded and unchanged filesystem files with their original source and does not chain another overlapping runtime loader. NUL-prefixed virtual ids remain outside the adapter’s filesystem filter. Each Bun.build opens a delivery pass through onStart, then onEnd disposes the completed project generation and its filesystem trackers.
For the Bun runtime, bun run, bun test, or any bun <entry>.ts with no bundling step, register the transform on Bun’s module loader with @ttsc/unplugin/bun-register. Add a bunfig.toml preload once:
preload = ["@ttsc/unplugin/bun-register"]Now every bun run / bun test applies your ttsc plugins as files are imported, with plugin options from the nearest tsconfig.json. To pass options, or register imperatively, call it from your own preload module instead:
// bun-preload.ts (bunfig.toml: preload = ["./bun-preload.ts"])
import { register } from "@ttsc/unplugin/bun-register";
register({ project: "tsconfig.build.json" });Importing @ttsc/unplugin/bun-register registers exactly one runtime loader, shared by CommonJS and ESM loads in the same Bun process. Calls made before the first transformable TypeScript load use last-call-wins and capture their options by value, so an immediate explicit call can replace the preload defaults. Entering the first such load locks the options synchronously for the immutable module-loading session. A later call with the same structural value is idempotent, while a different value throws. Restart the Bun process after changing registration options, source, tsconfig, or plugin inputs.
Options
Most projects need none: the adapter finds tsconfig.json on its own, and your plugins and lint.config.ts apply unchanged.
The adapter starts from the nearest tsconfig.json. For a solution config with "files": [] and references, it searches referenced projects depth-first in declaration order and uses the first that includes the file. A reference may name a config file or a directory containing tsconfig.json. In a Vite layout, this selects tsconfig.app.json for src/App.tsx and tsconfig.node.json for vite.config.ts.
Searched configs and missing candidates remain watched. Changes to references or include, or creation of a missing config, can change project selection. A file admitted by no project passes through untransformed with a notice naming the searched projects.
These options layer on top:
-
projectRootsets a common source root when the selected config includes sibling workspace packages. Relative paths resolve fromprocess.cwd(); omission uses the config directory. Config-relative compiler paths, plugin package resolution, factory contexts and plugin config discovery keep their original config directory. For example, a config underbackendthat includes../library/srccan usettsc({ project: "backend/tsconfig.dev.json", projectRoot: "." })from the workspace root. -
projectpoints at a non-default config file, resolved fromprocess.cwd():
ttsc({
project: "tsconfig.build.json",
});compilerOptionsoverrides compiler settings for this build without another config file.pluginsreplaces the project’s plugin list entirely;plugins: falsedisables them.precompilelists source entry paths whose projects should start compiling together on the first transformable load. Paths resolve fromprocess.cwd()and follow the sameproject,projectRoot, compiler options, plugins and Vite aliases as ordinary loads. Several entries in one project share its compilation. A listed entry opts its project into compilation even if the host never imports it; omitted entries remain demand-driven. A preparation failure is reported when that module is loaded, so it does not fail an unrelated project’s load.
Path aliases
Under Vite, the adapter reads the resolved resolve.alias and layers it onto the generated config, so an alias declared only in vite.config.ts still resolves during the compile. No other host’s alias configuration is read: under Rollup, Rolldown, webpack, Rspack, esbuild, Farm, Turbopack and Bun, the compile resolves through the tsconfig’s own paths alone. Declare an alias in paths when a module has to resolve for the compiler as well as for the bundler, which is what those hosts need anyway for tsc to type-check the same imports.
Not every Vite alias form can be forwarded, because a tsconfig paths map cannot express all of them:
resolve.alias form | Forwarded |
|---|---|
a string find whose replacement is an absolute path | yes |
a replacement with a leading / outside the root, such as { "@": "/src" } | yes, as the Vite root’s src first and the absolute /src second, the order Vite tries them |
a relative replacement, such as "./src" | no: Vite resolves it against each importing module, which paths cannot express |
a bare replacement, such as "lodash-es" | no: Vite resolves it as a package, which paths cannot express |
array form with a RegExp find, such as { find: /^~/ } | no: paths has no regular-expression form |
a string find containing * | no: a paths key already reads * as its own wildcard |
Declare unforwarded aliases in the tsconfig’s paths. For example, represent the prefix /^~/ with a "~/*" entry. Regular expressions are not converted automatically because their matching rules can differ from paths wildcards.
A string find containing *, or a relative or bare replacement, produces one stderr notice naming the alias and reason. Regular-expression aliases produce no notice, since Vite adds its own for @vite/env and @vite/client.
Source maps
Every transformed module comes with a source map back to the text you wrote, so stack traces, breakpoints, the error overlay, and a production build’s maps point at your own lines. That holds even though the compiler reprints the file and a plugin such as @ttsc/banner or typia inserts code above it. Vite, Rollup, Rolldown, Farm, and Turbopack receive the map with the transformed code. webpack and Rspack receive it from a loader the adapter places right after its own, because unplugin’s loader hands a transform’s map on only to a module that already carried one, and so to none that ttsc transforms first. esbuild and Bun take it as an inline sourceMappingURL in the loaded contents. Under Metro, whose transformer returns an AST rather than a map, @ttsc/metro moves the upstream transformer’s AST locations back to your lines, and Metro builds its map from them.
A module gets a map only when the compile read exactly the text the bundler delivered. When an earlier plugin already changed that text, the module is still transformed, but without a map, because a map of the file on disk would point at the wrong lines. An executable plugin host that writes no maps keeps working the same way.
Cache and watch invalidation
One project compilation supplies every module’s transformed output. The adapters reuse it while its inputs remain current: source files, resolved declarations, configs, resolution candidates, plugin dependencies, and plugin Go sources and build environment.
precompile changes when known projects start, while keeping their input proofs and normal module delivery. Restarting a dev server after an edit does not prove that inputs stayed unchanged during compilation, and a source watcher cannot observe an external Go toolchain or a plugin’s dependencies. Preparation therefore keeps the same post-compile proofs as demand-driven compilation and publishes host watch records only when a module is actually delivered.
Watching Vite servers track compiler inputs separately from Vite’s runtime import graph. Type-only dependencies can trigger HMR without becoming runtime imports. Vite decides whether to preserve component state or reload the page; with hmr: false, the adapter requests a full reload.
Other bundlers watch a project record below the host root’s .ttsc/records directory. It changes when compiler inputs change, including files created or removed while the bundler is stopped. The adapter validates these records before a persistent cache is reused.
For webpack and Rspack, include the bundler configuration in cache build dependencies so changing adapter options invalidates cached modules: cache.buildDependencies.config for webpack and cache.buildDependencies for Rspack.
If the adapter cannot write a project record below the host root, it uses a temporary directory where the bundler permits it. Turbopack and Farm across drives cannot use that fallback. The adapter then reports TTSC_PROJECT_RECORD_UNWRITABLE, disables caching where supported, and refuses watching transforms that could become stale. Make the host’s .ttsc directory writable to restore watching.
What the snapshot covers
The snapshot covers selected project files and external compiler inputs, including linked workspace sources, declarations under node_modules, the tsconfig extends chain, plugin dependencies, and failed resolution candidates. Creating a previously missing input can invalidate a transform. Built-in and linked transform hosts report this graph automatically; executable sidecars use the driver SDK.
Paths are compared by filesystem identity, including symlinks, Windows junctions, and case-insensitive filesystems. The adapter checks input stability around compilation before retaining output.
Delivery passes
Build hosts open a pass for each build or rebuild. The first module validates the retained project compilation; subsequent modules use that result. Metro workers, Turbopack loaders, and watching Vite servers validate each delivery. Unchanged rebuilds reuse compiled output.
A Vite server with server.watch: null registers no watchers and uses the build-pass lifecycle. Each Bun build shares one compilation across its modules and releases it at build end; Bun runtime imports share one immutable process-scoped loading session.
Some runtimes cannot observe all plugin input reads. A coherent local result can still supply the first delivery of each recorded module in an explicitly nonwatching pass when the host supports cache withdrawal. It cannot become a persistent project record or a shared worker publication. Repeated deliveries and later passes compile again; watching or unknown lifecycles reject incomplete observation. Turbopack production loaders declare their nonwatching lifecycle and use cacheable(false) when that callback is available.
Program membership
Root-file discovery follows the effective TypeScript-Go configuration, including inherited files, include, exclude, allowJs, resolveJsonModule, and ${configDir} substitutions. Imports and plugin dependencies remain tracked outside that root-file selection.
An explicit exclude, including an empty array, replaces TypeScript’s implicit output-directory exclusions. Exclude emitted JavaScript directories when allowJs would otherwise admit them into the project.
Proving an input unchanged
Unchanged input metadata can avoid repeated content reads. A changed signature falls back to content comparison. Watchers detect changes to input membership, missing paths, and links; unavailable or failed watchers fall back to snapshot validation.
For the implementation and platform-specific observer rules, see Unplugin Invalidation.
Inputs that are not there
Missing files and resolution candidates remain tracked, so adding a declaration or package can trigger recovery without editing its importer. On filesystems that do not reliably report native notifications, enable polling through your host:
- Vite:
server.watch.usePollingorCHOKIDAR_USEPOLLING. - webpack and Next.js:
WATCHPACK_POLLING.
On macOS, native watching requires the optional fsevents dependency. Without it, TTSC_FSEVENTS_MISSING is reported once, cached generations use snapshot validation, and Vite compiler-input watches poll.
Narrowing by declared completeness
A plugin may declare its dependency list complete for a file. Vite and Metro then track that list instead of the full reference closure. The built-in syntactic transform and first-party @ttsc/banner and @ttsc/strip use this contract where applicable.
This also narrows when type diagnostics refresh. An error introduced outside the declared list appears at the next compilation triggered for another reason. Run ttsc --noEmit --watch alongside the bundler for continuous project-wide checking.
Failures and lifecycles
Plugins whose output depends on non-file inputs declare those files volatile to exclude them from caching.
Changed inputs during compilation cause a bounded stabilization retry. Incomplete output is not retained for reuse.
Failed transforms retain known recovery inputs so repairing an internal or external dependency can trigger another compile, including after a failed first build. Unchanged failures are shared within a build pass. Closing the host releases its observers and sessions.
If a successful compile has no output for a requested module, the adapter passes it through and reports the file and selected tsconfig once per pass. Add the file to that project if it needs plugin transforms.
Transform caches and source-plugin binary caches are independent. Persisting .next/cache does not preserve compiled Go plugins. See Build cache, and run ttsc prepare from the same project directory the bundler builds.
CLI or bundler?
Whichever tool owns the build runs the pass. A plain Node library builds with npx ttsc. A frontend behind Vite or webpack builds through @ttsc/unplugin. A monorepo with both uses both, one per package. The plugin pass is identical everywhere, and diagnostics show up wherever the bundler surfaces them.