Execute (ttsx)
ttsx runs a TypeScript file directly, like tsx or ts-node, but it type-checks first and applies your plugins.
Run a file
npx ttsx src/index.tsttsx:
- Resolves the entry’s project: the nearest
tsconfig.jsonabove the entry, or-P path/to/tsconfig.json. When the nearest config is a solution that does not contain the entry, ttsx uses the referenced project that does. See Solution-style configs. - Type-checks the project, including every plugin you’ve configured.
- If the check passes, executes the script natively.
Type errors stop execution. So do @ttsc/lint errors (warnings don’t).
The entry can also be JavaScript, such as a tool’s bin script run so that the TypeScript configuration it loads is served: npx ttsx node_modules/webpack-cli/bin/cli.js --config webpack.config.ts. A JavaScript entry has no project to check up front. It runs as it would under node --require ttsc/register, and each TypeScript file it reaches is checked and built through its own project before it runs. The options that only configure the up-front build (-P, --cache-dir, --checkers, --singleThreaded, --no-plugins, and forwarded compiler options) are refused before a JavaScript entry; set compiler options in the tsconfig.json that owns the TypeScript it loads.
ttsx runs on Node.js 22.15 or later, the first release with module.registerHooks. Bun and Deno report a Node.js version but implement neither those hooks nor the loading ttsx depends on, so ttsx and ttsc/register refuse them with a pointer to Node.js instead of running the file some other way. bunx ttsx uses Node.js unless --bun is given.
The entry runs as Node’s own main module, exactly as under node <entry>. require.main === module and import.meta.main hold in it, process.argv[1] is the entry, an error it throws reaches process.on("uncaughtException"), and the exit status is the program’s. That holds when an --import preload in NODE_OPTIONS, as OpenTelemetry and Sentry install themselves, makes Node run the entry through its ESM loader.
ttsx stays in front of the program as its parent and behaves the way a shell does. SIGTERM and SIGHUP sent to the ttsx process, as a supervisor or docker stop does, are forwarded to the program. SIGINT from a terminal already reaches the program through the process group, so it is not sent twice. When the program ends, ttsx removes its private runtime directory, including when a signal ended it. It then ends the same way: with the program’s exit code, or by the same signal, which a shell reports as 128 + n. A signal that arrives while the project is still being checked stops the run after that step has cleaned up.
Register in Node and test runners
When Node, Mocha, or another JavaScript tool owns the process, preload the ttsx runtime from the ttsc package:
node --require ttsc/register src/index.ts
mocha --require ttsc/register --extension ts,tsx "test/**/*.ts"The package is named ttsc, even though its typed execution command is ttsx, so the resolvable preload is ttsc/register. The short Node spelling -r ttsc/register is equivalent to --require ttsc/register, and --import ttsc/register works as well. With --import, Node runs the entry through its ESM loader, and a CommonJS entry is still the main module.
The preload installs the same runtime hooks as ttsx. When a TypeScript main module or a TypeScript file loaded by a JavaScript host first enters the runtime, ttsc/register discovers its project (the nearest tsconfig.json, or the referenced project that owns the file under a solution-style config), checks the project, applies configured plugins, and executes the compiler-owned JavaScript. A test file outside the config’s include still inherits that config’s compiler options and plugins, just like an out-of-include ttsx entry. A diagnostic stops that root before any of its statements execute.
Several roots and tsconfigs can enter one runner process. Their temporary emits coexist for that process and are removed on exit. Register-owned output uses the same workspace-local ttsx cache as an ordinary run and stays temporary.
Registration is a one-shot load contract, not watch mode. Editing a source after the host has loaded it does not invalidate Node’s module cache or re-run the compiler. Restart the runner for a fresh check. To propagate the preload to Node child processes, put --require ttsc/register in NODE_OPTIONS.
Scripts your tsconfig does not include
ttsc and ttsx select different things from the same tsconfig.json. ttsc selects a file set: a project whose include is ["src"] compiles only src into outDir, and a clear.ts, a build/release.ts, or a lint.config.ts sitting beside the tsconfig stays out of the output. ttsx selects an entry: it needs that project’s compiler options, not its file list.
build/release.ts ← runs under ttsx, never lands in lib/
clear.ts ← runs under ttsx, never lands in lib/
lint.config.ts ← never lands in lib/
src/**/*.ts ← the only thing ttsc compiles into lib/
tsconfig.jsonSo npx ttsx clear.ts works even though npx ttsc ignores that file. The entry is compiled through a project that inherits the discovered tsconfig.json — strict, target, types, paths, and the module format — and declares only that entry. It compiles into a private directory, so your outDir never gains a file, and its rootDir covers the whole volume, so the entry can import sources from anywhere on the same drive, a sibling package included. composite is switched off for that build, because it requires every imported file to be listed. paths applies to the type-check exactly as it does for src; like anywhere else in TypeScript it is not a runtime resolver, so a path alias still needs a real module resolution at run time. The entry is still type-checked: a type error in clear.ts stops the run exactly as one in src would. The project itself is built first either way, so a type error anywhere in src stops the run too.
A project that keeps its sources under the directory holding its tsconfig.json needs no rootDir of its own. ttsx compiles into a cache directory of its own and pins the source root the compiler would otherwise infer — that same config directory — so a check-only project with noEmit: true and its sources under src/ runs unchanged, with no .js left beside them. Positional ttsc <file.ts> resolves it the same way, and a rootDir you do declare is always used as written. Unlike ttsx, positional ttsc compiles only a file its project includes: for any other file it stops with an error that names the file and the tsconfig, rather than writing some other file’s JavaScript under its name. A project that pulls in sources from above its config directory is the one case that still has to say so: declare the rootDir that contains them, or the compiler reports the file it cannot place.
Every build ttsx starts writes all of its output into the run’s private directory: JavaScript, source maps, declarations, and build information alike. A declarationDir, tsBuildInfoFile, or outFile in your config, and an output location forwarded on the command line such as ttsx --outDir dist src/index.ts, still take part in the type-check, but they never write into your tree. This holds for the entry project, an out-of-include entry, dependencies built through their own tsconfig.json, and ttsc/register.
The module format follows the same rule everywhere, because it is the rule the compiler itself applies when it emits. An explicit .cts/.mts extension wins. A package under node_modules that declares a "type" decides for its own files whatever the compiling project asked for, which is how a dependency that ships CommonJS source stays CommonJS. Otherwise the project’s module option decides, and only the node16/node18/node20/nodenext family — plus a file no project compiles at all — takes its answer from the nearest package.json "type". With no module option set, the format is derived from target.
Projects that enable allowImportingTsExtensions are supported. During the runtime emit, ttsx rewrites relative .ts, .tsx, .mts, and .cts import specifiers to the matching JavaScript extensions so Node can execute the output. The emitted JavaScript lives in a per-run cache directory and is removed after the script exits.
Files the program reaches at run time
The up-front build covers what the compiler can see: the entry and everything it imports. A program can also reach TypeScript the compiler never saw. It can require() a file by path, load a file outside include, or write a .ts file and then import it. ttsx serves such a file through its own project: the nearest tsconfig.json above it, or, under a solution-style config, the referenced project that contains it. That project is built once per run, emit-only like any dependency, and serves every file it compiled. A file that build did not compile either is a root of its own. It is compiled alone through the project’s options and plugins before it runs:
- In your own tree, the root is type-checked. A type error stops the run before any of that file’s statements execute, just like a type error in the entry.
- Inside
node_modules, the root is emitted without the type gate, the same way the rest of that package is served. A workspace package linked intonode_modulesis your own tree, because its files live outside it. - While
ttscevaluates a plugin descriptor, a root the descriptor reaches is emitted without the type gate too. A descriptor is tooling the compiler runs, and your project’stypes,lib, and strictness were never chosen for it.
A diagnostic never withholds an emit-only build’s JavaScript, so a noEmitOnError in that project’s config is switched off for it. A root is compiled through a temporary tsconfig that extends the project’s. For a type-checked root, and for an entry outside include, that file is .ttsx-<role>.<key>.tsconfig.json beside your tsconfig.json for the length of the build, because ${configDir}, types, and the default typeRoots resolve from the directory of the config the compiler is given. The compiler’s command line cannot combine a project with a file list, so this file is the only way to hand it the project’s options for one file. The directory has to accept it: in a read-only checkout, mount, or volume, ttsx stops and names the directory, and adding the file to the project’s include or files avoids the temporary file altogether. An emit-only root’s tsconfig goes into the run’s private directory instead, so an installed package’s directory is never written to, unless that package’s config uses ${configDir}.
A file that no tsconfig.json owns gets an isolated emit in its runtime module format. That covers a file with no config above it, and a file inside node_modules whose own package has none. The emit is cached across runs under a key of the file’s bytes, the compiler, and ttsc itself, and recorded only when the file held still from the read that keyed it through the emit that lowered it, so a file edited while it was being lowered is lowered again on the next run.
A file only ever runs from JavaScript compiled from that same file. ttsx identifies files by their filesystem identity, so a Windows short path, a symlink, or a different letter case still names the same file. Another file that shares its name, such as a project’s src/index.ts for a package’s index.ts, never stands in for it.
Decorators
For runtime users, standard TC39 class and member decorators work in both ESM and CommonJS, including ttsc/register, entries outside include, and dependencies that ship TypeScript source. Keep experimentalDecorators unset for standard decorators; enable it only for TypeScript’s legacy decorator API.
TypeScript-Go preserves proposal syntax when target is ESNext. For execution, ttsx lowers that target to ES2025 in its private runtime build, retaining the original library selection and module kind. Lower targets keep their configured value. The same rule applies when --target selects ESNext on the command line or in a compiler response file. The project’s tsconfig.json, source files, and ordinary ttsc build output are unchanged.
JSX
A project that sets jsx to preserve or react-native keeps JSX in its output for another tool, as Next.js projects do by default. Node cannot parse JSX, so ttsx compiles it in its private runtime build the way the type-check already reads it:
- A
jsxImportSourceselects the automatic runtime (react-jsx), even when the project also declares a classic factory.preserveallows both, and the type-checker then reads JSX through the import source, so the factories are set aside for the runtime build. A file marked@jsxRuntime classicin such a project then needs its own@jsxand@jsxFragpragmas. - Otherwise a
jsxFactory,jsxFragmentFactory, orreactNamespaceselects the classic transform (react). - A project that declares none of these gets the automatic runtime with its default import source,
react. The automatic runtime also reads per-file@jsxImportSourcepragmas. A file that relies on a per-file@jsxfactory pragma needs@jsxRuntime classicbeside it, as it would underreact-jsxanywhere.
The classic transform compiles a fragment (<>…</>) through jsxFragmentFactory, so a project that declares only jsxFactory and uses fragments needs both, as it would under react.
The same rule applies to a --jsx value forwarded before the entry or inside a response file, to dependencies built through their own tsconfig.json, and to ttsc/register. A .tsx file with no project compiles with the automatic runtime. The react, react-jsx, and react-jsxdev modes already produce executable JavaScript and are used as configured. Your tsconfig.json and ordinary ttsc output are unchanged.
Raw-TypeScript dependencies
The type-check covers your whole program, but at runtime your dependencies are loaded by Node directly. When part of the graph is ESM, Node trips over dependencies that ship raw TypeScript:
- A workspace dependency (a
node_modulessymlink whose real files live outsidenode_modules) gets its types stripped by Node, but its own extensionless relative imports (import "./util") are rejected withERR_MODULE_NOT_FOUND. - A published dependency that ships
.tsinsidenode_modulescannot be loaded at all, because Node refuses to strip types undernode_modules(ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING).
ttsx installs Node module hooks in the run, the same approach tsx uses, so both cases just work without touching the dependency’s exports, sources, or tsconfig:
- a
resolvehook probes the candidate extensions (and directoryindexfiles) for an extensionless relative import anywhere in the graph, and maps a relative.jsspecifier — the spelling TypeScript asks authors to write — back to the TypeScript source it names when nothing emitted that JavaScript; - a
loadhook serves every TypeScript file from JavaScript compiled from that file. A file the up-front build compiled comes from that build. Any other file comes from a build of its own project: a workspace neighbour or published package that has atsconfig.jsonis built once per run and shared by every process of the run. A file with no project gets an isolated emit. Files the program reaches at run time describes each lane.
Both reaches cover the CommonJS require graph as well as the ESM one, on every supported Node, through module.registerHooks alone. A CommonJS module handed to the ESM loader with its source resolves its own require() around the hooks on some releases, and an ESM import of a CommonJS module is how a plugin loads a config file it discovered. ttsx therefore gives such an importer an ESM module that loads the CommonJS module through the CommonJS loader, where the hooks see its require(), with the namespace Node would give it: default as module.exports, module.exports itself on releases that export it, and each name Node’s static export detection finds. On Node 22, once any module hook exists, Node gives every CommonJS module an import reaches, JavaScript included, a narrower require with no require.cache, require.extensions or require.resolve.paths that cannot load TypeScript. ttsx asks the runtime whether it does when it first matters, and only there loads JavaScript CommonJS modules an import reaches the same way. The entry itself is handed to Node as CommonJS so it stays the main module; on Node 22 it then has that narrower require, as any hooked CommonJS entry does there, and the TypeScript it requires is served to it. require.resolve reaches the hooks only on newer releases. ttsx asks the runtime whether it does when it starts, and only where it does not does it rescue a refused require.resolve of a .js or extensionless source spelling at the CommonJS resolver. require.extensions lists .ts, .tsx, and .cts, each bound to Node’s own .js handler while the hooks serve the source, because CommonJS tools such as rechoir (used by webpack-cli, gulp-cli, and knex for a .ts config) take that key as the sign that TypeScript can be required, and Node defines none. A key another loader already set is left as it is. Node probes those keys after its own for an extensionless require, so a lone x.ts is found and an x.js beside it still wins.
These hooks are runtime-only and never weaken the compile gate: the up-front tsgo build still deep-checks the whole program, imported workspace neighbours included, so a type error in a dependency’s source fails the run before anything executes. A genuinely missing module still throws ERR_MODULE_NOT_FOUND, or MODULE_NOT_FOUND with its require stack on the CommonJS graph. This makes ttsx strictly stronger than tsx: a full type-check plus the same runtime reach.
On supported Node releases whose CommonJS resolver strips the node: scheme from prefix-only builtins such as node:sqlite, ttsx restores only that exact native result before loading it. Ordinary builtins, ESM imports, and custom module-hook remaps keep their original resolution.
When an ESM entry imports named bindings from a CommonJS-classified source, ttsx also exposes names re-exported through nested export * barrels. TypeScript-Go lowers them to __exportStar(require("./x"), exports), whose target Node would read from disk, where only the TypeScript source exists, so the names are read from the target’s emit instead, as Node would read them had that JavaScript been there.
Source maps, stack traces, and coverage
ttsx runs the tsgo-built JavaScript under your original .ts path, so it inlines a source map into every served file and enables Node’s source-map support in the run. Error stack traces therefore report the true .ts line and column out of the box — no --enable-source-maps needed — and V8 coverage (c8, NODE_V8_COVERAGE) attributes executed lines to the real source, so a coverage gate over a ttsx run is trustworthy. This holds regardless of the project’s own sourceMap setting (the map is forced onto the private runtime emit and never touches your published outDir) and for both entry-project files and raw-.ts dependencies.
Pass arguments to the script
Everything after the entry file is the script’s own argv, the same as node:
npx ttsx src/server.ts --port 3000 --watchttsx reads options only before the entry, so --help, --version, -r, or --watch after it reach the script’s process.argv unchanged. The entry is the first argument that is not an option’s value, whatever its extension: whether an option takes a value comes from ttsx’s own options and from the compiler’s option table, so in ttsx --target es2020 src/server.ts the entry is src/server.ts. A -- directly after the entry is accepted as an optional separator and dropped (ttsx src/server.ts -- --port 3000 passes --port 3000). Any later -- is an ordinary argument, so ttsx src/args.ts a -- b passes a -- b.
ttsx has no watch mode. --watch before the entry is refused with a pointer to ttsc --watch --noEmit, for a watching type-check, and to node --watch --require ttsc/register src/index.ts, which restarts the program on changes and checks each start.
Preload modules
Same as Node’s --require:
npx ttsx -r ./preload.cjs src/index.ts
npx ttsx -r dotenv/config src/index.tsRepeatable: -r a -r b preloads a then b. These modules are passed to Node’s --require loader after the runtime hooks are installed, so a TypeScript preload is compiled like any other file the program reaches. Its own project builds it, and when that project does not include it, it is compiled alone and type-checked in your tree. See Files the program reaches at run time.
Solution-style configs
Vite, Nx, and many monorepo templates generate a root tsconfig.json that owns no files and delegates them through references:
// tsconfig.json
{ "files": [], "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] }ttsx, ttsc/register, and the runtime hooks pick a file’s project the way your editor does. When the nearest config does not contain the file, they search its references in order, and the first referenced project whose files include it owns it. A reference may name a config file or a directory holding tsconfig.json. A referenced solution is searched through its own references, and a reference cycle is visited once. So ttsx src/main.ts runs with tsconfig.app.json’s options, and ttsx vite.config.ts runs with tsconfig.node.json’s. A file that no referenced project contains still runs through the discovered config, like any script outside include. -P always wins and skips the search.
Pick a tsconfig
npx ttsx -P tsconfig.scripts.json src/seed.tsFlags
| Flag | Meaning |
|---|---|
-P, --project <file> | Use an explicit tsconfig.json. |
--cwd <dir> | Resolve the entrypoint and project from this directory. |
--cache-dir <dir> | Override the runtime and source-plugin cache root. |
--binary <path> | Use an explicit TypeScript-Go binary. |
--no-plugins | Build the entry’s project without loading ttsc plugins. |
-r, --require <module> | Preload a module before the entrypoint. Repeatable. |
--singleThreaded | Run TypeScript-Go single-threaded. Mirrors tsc --singleThreaded. |
--checkers <n> | Type-checker pool size. Mirrors tsc --checkers. |
-h, --help | Show help. |
-v, --version | Print the runner version. |
Any flag ttsx does not own, placed before the entry file, is forwarded to the tsgo type-check, ttsx --strict src/index.ts works like the matching tsgo invocation. A flag that only suppresses output, --noEmit or --emitDeclarationOnly, changes nothing about that check, and the private build the program runs from still emits. Flags after the entry go to the running program as its own process.argv, the same as node.
When ttsx vs ttsc
| Task | Use |
|---|---|
| One-off script, fast feedback | ttsx src/script.ts |
| Build for deployment | ttsc |
| Run inside CI to gate on types | ttsc --noEmit |
| Run inside CI to gate on types and execute tests | ttsx test/index.ts |
ttsx vs tsx vs ts-node
tsx | ts-node | ttsx | |
|---|---|---|---|
| Transpile speed | Fast | Slow | Fast |
| Type-checks before running | No | No (by default) | Yes |
| Plugin support | No | Custom transformers | First-class |
| TypeScript-Go runtime | No | No | Yes |
If you currently use tsx: switching is one command; ergonomics are the same.
Plugins and cache
ttsx uses the same tsconfig.json and plugins as ttsc. If a lint or check plugin reports an error, the script never runs. Transform plugins rewrite the code before it executes.
Compiled JavaScript is temporary and cleaned after the script exits. A run terminated outright cannot clean up, so the next run removes its directory once none of that run’s processes is alive, and ttsc clean removes one in the cache root as well. By default, ttsx and source-plugin builds resolve one workspace-local cache root: runtime output goes under <root>/ttsx/project/<pid>-<nonce>, a directory of each run’s own that the process id names for a reader and the nonce keeps apart from any other run with that id, and plugin binaries under <root>/plugins. A nested tsconfig therefore shares its package or workspace cache instead of creating a node_modules directory of its own, and a prepared plugin binary stays warm. TTSC_CACHE_DIR relocates that shared root. --cache-dir supplies an invocation-local root resolved from --cwd, with runtime output under <cache-dir>/project/<pid>-<nonce> and plugins under <cache-dir>/plugins. When the default workspace directory refuses writes (a read-only checkout, mount, or container filesystem), the runtime output falls back below the system temp directory. Use npx ttsc clean --cache-dir <dir> to wipe an explicit plugin cache, and persist only the plugin paths reported by ttsc cache paths, not the transient ttsx directory.
Dependencies built through their own tsconfig.json are built once per run and shared by every process of that run, then removed with it. A process started by your program that has lost the run’s manifest, because its environment dropped TTSX_RUNTIME_MANIFEST or because it outlived the ttsx launcher, builds into a directory of its own that is removed when it exits. Nothing a later run reads was built from older sources.
Raw TypeScript that no tsconfig.json owns is lowered once and cached across runs under TTSC_CACHE_DIR when it is set, or the system temp directory otherwise. The cache key covers the source, its module format, the emit arguments, the ttsc version, and the compiler binary’s identity, not only its path. Upgrading typescript in place therefore produces the new compiler’s output on the next run without clearing anything.
See also
- TTSC ·
ttscCLI: the type-check / build front of the same pipeline. - Lint & Prettier: the linter that runs alongside the compile.
- FAQ: common runner gotchas and Node-version questions.