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 declares ttsc as a peer dependency pinned to the minor it was published with. Both packages are released from one repository at one version, and the adapter compiles against ttsc’s exported compiler surface, so a 0.x minor is allowed to change what it imports. The pin is what turns that into an install error instead of a runtime failure in your bundler.
The consequence shows up with automated updates. A bot that opens one pull request per package will raise ttsc alone, and the install fails:
npm error While resolving: @ttsc/unplugin@0.25.0
npm error Found: ttsc@0.26.0Nothing is wrong with either package. The two updates are one update, so tell the bot that.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
groups:
ttsc:
patterns:
- "ttsc"
- "@ttsc/*"That raises one pull request carrying every ttsc package your project installs, which is the only shape that can pass. Renovate expresses the same thing as a packageRules entry with groupName over the same patterns.
Pin both to the same version in package.json for the same reason. If you upgrade by hand, upgrade them in one commit.
Vite
// vite.config.ts
import ttsc from "@ttsc/unplugin/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [ttsc()],
});Rollup
// rollup.config.ts
import ttsc from "@ttsc/unplugin/rollup";
export default {
input: "src/index.ts",
output: { dir: "dist", format: "esm" },
plugins: [ttsc()],
};One caveat worth knowing before it costs you an afternoon: the adapter emits transformed TypeScript, not JavaScript, and plain Rollup does not compile TypeScript itself. Add an esbuild- or swc-based plugin after ttsc(), or the build fails parsing type syntax. Bundlers that strip types on their own (Vite, Rolldown, esbuild, Bun) need no such step.
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()],
};Rspack
import ttsc from "@ttsc/unplugin/rspack";
export default { entry: "./src/index.ts", 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.
That last part is decided by exact spelling. withTtsc declines to add its own rule when your glob is one it has measured against a real Turbopack build as naming every file with the extension — *.ts, **/*.ts, {**/,}*.ts, *.tsx, **/*.tsx, *.{ts,tsx}, {*.ts,*.tsx}, **/*.{ts,tsx}, **/{*.ts,*.tsx}, **/**/*.{ts,tsx}. Any other spelling keeps your rule and gains ours beside it, so a module may be transformed twice rather than not at all. The list is exact on purpose: treating an unmeasured glob as project-wide once left every module in a project with no ttsc rule at all, and a build that transforms twice is recoverable in a way a build that never transforms is not.
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"] },
},
},
};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 two above still passes declaration files, node_modules paths, non-TypeScript sources, and virtual ids through untouched.
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. Bun.build clears the project generation through its onStart lifecycle on every build.
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" });One runtime registration is one immutable module-loading session. Restart the Bun process after changing 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. Three options layer on top:
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.
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 |
|---|---|
{ "@": "/src" }, or the array form with a string find | yes |
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 |
In both unforwarded cases the compile resolves that specifier through the tsconfig’s paths alone, so declare it there if ttsc must resolve through it — a prefix RegExp such as /^~/ is written as a "~/*" entry. Reducing simple prefix patterns automatically is deliberately not attempted: distinguishing /^~/ from /^@app/, which also matches @apple, needs enough of a regular-expression engine that a wrong reduction becomes likely, and a mistranslated alias resolves imports to the wrong file without saying so.
A find containing * is also reported once on stderr, naming the alias and the reason. A RegExp find is not, and the asymmetry is deliberate: Vite merges two RegExp aliases of its own into every resolved config, for @vite/env and @vite/client, so a report on that form would fire in every build of every project and name aliases you never wrote.
Cache and watch invalidation
The compiler also reports module-resolution candidates that would outrank a selected target. @ttsc/unplugin registers candidates belonging to importers in a transformed file’s reference closure, so creating or changing a higher-priority probe invalidates the transform even when neither the importer nor its tsconfig changed.
A dev server configured without a watcher (server.watch: null, which is how vitest --run and other one-shot consumers configure Vite) receives no watch registration at all. Nothing can deliver a change event there, while Vite’s import analysis resolves every registered path like a real import of the transformed module, once per module. The adapter’s own missing-input poll below is unaffected, because it never depended on Vite’s watcher.
Under a running Vite dev server that does watch, the adapter splits that registration by existence. Vite serve resolves every watch registration made during a transform as if the module imported it, so a candidate that does not exist yet, which is the normal state for a higher-priority probe, would fail the request that just transformed correctly. Existing inputs keep the ordinary watch registration; missing ones are polled on the filesystem instead (Vite’s own watcher does not look inside node_modules, where these probes usually point), and when one is created the adapter invalidates the modules that registered it and triggers a full reload, so the resident server picks up the new resolution without a restart. Production builds and every other bundler keep the single registration path.
Bundlers invalidate a module only when its registered inputs change, and they erase type-only imports from their module graphs, but a type-driven transform’s output depends on the files where the consulted types are declared. @ttsc/unplugin closes that gap by registering, per transformed file, the compiler-reported reference closure (type-only edges included), the files contributing to the global scope, and the tsconfig extends chain as watch files. The JavaScript host also registers the descriptor’s loaded CommonJS graph, every package manifest inspected by auto-discovery, and plugin-declared hostInputs; first-party plugins use that declaration for implicit config discovery and evaluated config dependencies. Arbitrary project assets do not become universal inputs. Webpack filesystem caches (what Next.js persists under .next/cache), watch-mode rebuilds, and Turbopack fileDependencies all consume that registration, so editing a type another file’s generated code depends on rebuilds the consumer without deleting any cache.
This works for every adapter automatically when the transform host emits the envelope’s graph section. The built-in host and linked plugins always do; executable sidecar plugins adopt through the driver SDK. Plugins whose output additionally depends on non-file inputs declare those files volatile, which excludes them from caching entirely.
A plugin that knows exactly which declarations it consulted can go the other way and declare its reported list complete for a file. The adapter then registers only that list plus the tsconfig chain for it, so a change to a file the transform never read stops re-running the loader. Nothing is required of you: the field is per file and opt-in per plugin, and a plugin that never declares it keeps the sound default above.
One whole-project compile already contains every module’s output, so the adapter compiles once and serves every module from that result. What follows is how it decides that the result still describes the project on disk.
What the snapshot covers
The transform cache snapshots every regular file reached by the non-following project walk, plus the graph-reported inputs outside that walk: node_modules declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root extends ancestry.
The project is snapshotted before and after compilation, and every graph member carries the compiler filesystem’s content and physical-identity proof. Only a complete generation that still matches both may authorize narrow reuse. That also rejects an A to B to A change whose restored post-compile bytes would otherwise hide the transient state the compiler actually read.
Delivery passes
A host with a build boundary opens a delivery pass there and keeps the generation it already holds. The pass’s first delivery proves the whole generation against the current filesystem once, and after that each module’s first delivery in the pass is settled by the supplied source alone. An incomplete generation never takes that shortcut.
A pass boundary states that each module is requested at most once inside it, which is a fact about deliveries rather than about whether the compiled program is still correct. buildStart repeats per rebuild under webpack, Rspack, Rollup, Rolldown, esbuild and vite build --watch, so discarding the generation there recompiled the whole project on every edit. The boundary now opens a pass and the recorded snapshot decides whether the generation survives it.
Long-lived hosts without a boundary (Metro workers, the Turbopack loader, and a watching Vite development server, whose initial buildStart spans later HMR edits) apply the file-specific validation on every generation hit. Those deliveries validate that file’s reference closure, globals, configs, resolution candidates, plugin dependencies, and exact host inputs, so sibling modules share one proof of the closure and the globals rather than repeating it per module.
Program membership
Membership comes from the resolved configuration rather than from a list of directory names. allowJs and resolveJsonModule decide which extensions can enter the program, outDir and declarationDir and the plain entries of exclude name the directories it does not contain, and only .git, node_modules and .ttsc remain excluded by name, because no tsconfig can name them.
A directory named by outDir, declarationDir or a plain exclude entry is not walked at all. Everywhere else the digest records the entries the walk considers rather than each directory’s own stamp, and a directory takes part only while its subtree can hold a program input. An output directory no configuration names therefore costs nothing, however its filenames churn, as long as the project cannot admit what it holds; under allowJs an emitted .js beside the sources is a program input like any other, so such a project should name that directory in exclude. Emptying and recreating a directory the configuration names, which emptyOutDir and output.clean do on every build, is not a change either, on a host with a build boundary or without one. A directory that takes no part is still walked and still watched, which is what makes the first source appearing in it a change the generation sees. The walk hashes only files that could enter the program, and validation compares only the generation’s declared inputs, so a tree of emitted files costs no reads.
Generation-scoped directory notifications detect new, removed, or renamed project inputs without repeating a directory-stat pass per module. Windows isolates those notifications so deletion of a watched temporary tree cannot crash the host. A reported membership event is evidence of a change and replaces the generation.
Proving an input unchanged
Every input a generation has already proven carries the nanosecond metadata signature captured around the read that proved it, so a later delivery that finds the signature unchanged reuses that proof instead of re-reading the bytes. Any signature change falls back to the full content comparison.
A signature is recorded only for a read nothing raced, only for an input whose recorded state came from bytes that were read, since the metadata of a path nothing could read holds still while the bytes behind it appear, and only once the observed filesystem’s own clock has provably left the tick that minted the input’s modification stamp.
A filesystem stamps writes once per clock tick, so a same-length rewrite landing inside the recorded stamp’s tick would leave the signature unchanged. Until some stamp the same filesystem minted later separates that tick, the content comparison keeps running, and the signature is re-earned the moment it can be.
The reference instants come from the filesystem itself, never from the process clock. Every stamp the adapter observes raises a floor kept per reporting device, and the adapter also stamps a probe in its own scratch directory, the way git separates racily-clean index entries with the index file’s own timestamp, to cover a tree whose files were all written inside one tick. The probe counts only when the scratch volume is the inputs’ volume, so on a split-volume layout the observed stamps carry the rule alone and an input they cannot separate is re-read rather than trusted.
Both sides of every comparison are therefore stamps of equal granularity minted by one clock, so a filesystem clock running behind the host changes nothing. What defeats the floor is a stamp set into the future rather than minted, such as a stamp-preserving extraction or copy from a machine whose clock ran ahead. A restored past stamp is harmless, since it never raises the floor. A clock that jumps backwards strands the floor above the present, which is a different hazard from a constant offset: an offset moves both stamps being compared, while a jump moves only the present. No stamp-based freshness proof survives either one.
Inputs that are not there
An absent resolution candidate is the one input a metadata signature cannot stand for, since a path that is not there has no metadata to compare. The generation registers those names with a watcher of their own, along with the directory components of the spelling that lead to them, down to the project’s own root, and a delivery reads the notification instead of re-probing each candidate.
That makes the notification the sole positive evidence for this one input class, which is why the registration reaches past the candidate itself: a watcher opened on a path that traverses a link follows it, so retargeting the link would otherwise move the answer without disturbing what is watched, and a package link inside node_modules is exactly that. It stops at the project root, and a candidate whose spelling leaves the project subtree before reaching it is not claimed at all, because above that line the components are the machine’s own layout rather than the project’s.
It listens for renames alone, since every event that can change a candidate’s answer is one: the file appearing, a component being created, replaced or retargeted. The directories carrying them have their attributes moved by anything written below.
A candidate whose watch could not be opened, whose watcher has since failed, or that belongs to a set spanning more directories than the generation will watch, is probed exactly as before. A host that runs out of watch descriptors fails the tracker outright, which costs every delivery a whole-project comparison rather than one probe.
What none of that covers is a filesystem that accepts a watch and then reports nothing, which some network mounts do. That is the same assumption the project-membership proof beside it already rests on, though a wider one here, since the project walk skips node_modules and a candidate under it was never covered by that proof. A project on such a mount should be treated as one where notifications do not work rather than one where they merely have not fired.
The barrier that lets a synchronous edit reach those watchers before a delivery reads their verdict is the watcher’s own acknowledgement rather than a fixed wait. An in-process watcher answers on the next turn of the loop its callbacks are queued on, and the Windows broker answers by an ordered round-trip, so a delivery waits for the crossing instead of guessing at it.
Narrowing by declared completeness
dependenciesComplete narrows file inputs by the same contract as watch registration, and a project reaches it without a type-driven plugin: ttsc’s own transform lanes print syntactically, so the host declares every file complete when no plugin can contribute to it, and the first-party @ttsc/banner and @ttsc/strip declare their own contribution the same way. Such a delivery validates the universal inputs and the file itself instead of its reference closure.
A narrowed file’s diagnostics narrow with it: an edit to a file the declaration dropped no longer re-runs the compile, so a type error introduced there surfaces at the next compile the build runs for another reason rather than at the edit. Run the compiler’s own check beside the bundler when you want it at the edit.
Envelopes without a graph, and generations whose watchers could not be opened or have since failed, retain complete-snapshot validation. Losing a notification is the absence of a membership proof rather than evidence of a change, so such a generation keeps validating against its own recorded state: the directory snapshot, the input hashes, and the universal descriptor inputs whose every rejection is evidence of a change rather than an inability to prove one.
Failures and lifecycles
For a caching host, an unstable or incomplete attempt is never published: all waiters share one bounded stabilization retry inside the same generation. A stable retry alone resolves and is reused, while a second unprovable attempt rejects the shared generation with bounded path-and-proof witnesses. That terminal verdict stays cached while its project, external, and host-input fingerprints remain unchanged, so later request waves pay only the confirmation probes and the compile count is independent of module count.
An input change or an explicit cache lifecycle reset permits a new generation. Opening a pass is not such a reset. A compile whose envelope failed outright is answered the same way: the pass retains that verdict for its remaining modules instead of repeating the identical failing compile once per module, and the next pass attempts it again. A host with no pass boundary keeps evicting a failed compile on every delivery, so a transient toolchain failure never becomes permanent for a long-lived worker. One-shot calls without a cache retain their single compile.
A compile that succeeded but has no output for one requested file is a fact about that file rather than about the generation, so the generation is left alone and the module is returned to the host untransformed, reported once per file per pass with the file and the tsconfig it is missing from. That is one answer shared by every adapter and by @ttsc/metro.
A failed compile registers the project inputs a fix would touch, so a watching session whose first compile failed still has a channel through which the fix arrives. Every message the adapter surfaces is plain text, since what it returns becomes a bundler’s error in an overlay or a CI annotation rather than something a terminal renders.
A generation’s non-error diagnostics belong to the compile rather than to a delivery, so they are surfaced once per pass rather than once per delivered module, and a later pass over a retained generation surfaces them again.
Bun runtime setup defines one process-scoped loading session. A dev server started without a watcher takes the pass lifecycle instead of persistent validation: server.watch: null leaves the session no channel through which a file change could ever reach it, so what per-delivery validation buys there is incoherence rather than freshness, since modules delivered before an edit and after it would come from two different compilations of one program. Each module’s first delivery in such a session is therefore settled against the generation the session started from, exactly as under a build, while a module the session already delivered keeps revalidating on its next request.
This section is about the transform cache (which files a rebuild depends on), not the source-plugin binary cache (the compiled Go plugin ttsc builds on first use). They are independent: persisting a bundler cache such as Next.js .next/cache does not carry the plugin binary across builds, and a fresh CI or container build recompiles the plugin (the ttsc: building source plugin ... line) even with a warm bundler cache. To keep that first-build compile from recurring on every build, persist the binary cache as described in Build cache. In a monorepo, run ttsc prepare from the same project directory the bundler builds, so the warmed binary matches the cache key the bundler build looks up.
Transform source outputs outside the project walk are kept in that external snapshot too. A non-declaration output is reusable only when the graph carries its compiler-time content and physical-identity proof; a post-compile disk read cannot prove which bytes produced the output. Its source hash then lets sibling module requests validate the same generation without adding arbitrary output keys to the project-file universe.
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.