cheat sheet
ts-node, tsx & Friends
Compare ts-node, tsx, Node 22.6+ --experimental-strip-types, Bun, and Deno for running .ts files directly; pick the right tool, configure watch mode, and avoid the classic ESM / type-strip pitfalls.
ts-node, tsx & Friends — Running TypeScript Without a Build Step
What it is
Running a .ts file directly is not something Node.js historically supported — TypeScript is a language Node doesn't speak, so a layer in front had to either compile to JS or strip types on the fly. Over the past decade four practical answers emerged: ts-node (the mature original, type-checking by default, slowest), tsx (esbuild-based, type-stripping only, fast and ESM-friendly), Node 22.6+ --experimental-strip-types (Node itself stripping types in-process, no extra package), and Bun / Deno (alternative runtimes that treat TypeScript as a first-class input). Reach for one of these during development, in scripts, in tests, and anywhere a build step would just slow the loop down. The trade-off — none of the type-strippers actually type-check; they erase annotations and run the result. You still need tsc --noEmit (or your editor's language server) to catch type errors.
Install
Each runner installs differently — ts-node and tsx are npm packages; the rest are baked into their runtime.
# ts-node — npm
npm install -D ts-node
# tsx — npm
npm install -D tsx
# Node 22.6+ — already installed if your Node is recent enough
node --version
# Bun — single binary
curl -fsSL https://bun.sh/install | bash
# Deno — single binary
curl -fsSL https://deno.land/install.sh | sh
Output: (none — exits 0 on success)
Verify each tool is on the path:
npx ts-node --version
npx tsx --version
node --version
bun --version
deno --version
Output:
v10.9.2
4.19.2
v22.11.0
1.2.18
2.1.6
Syntax
Every runner accepts a file path as its primary argument plus a handful of runner-specific flags. The flags differ — ts-node is the most flag-heavy, bun is the simplest.
ts-node [--esm] [--transpile-only] [--swc] file.ts
tsx [watch] file.ts
node --experimental-strip-types file.ts
bun file.ts
deno run --allow-* file.ts
Output: (none — exits 0 on success)
Comparison table
| Tool | Stripper backend | Type-check? | ESM | CJS | Watch mode | Cold start | Notes |
|---|---|---|---|---|---|---|---|
tsc | self | yes (full) | yes | yes | --watch | slow | Official emit, required for .d.ts files. |
ts-node | TypeScript compiler | yes (default) or no (--transpile-only) | partial (--esm) | yes | via nodemon | slow (with check) / medium (without) | Most config knobs; legacy choice. |
ts-node (SWC) | SWC | no | partial | yes | via nodemon | fast | Add swc: true to tsconfig's ts-node block. |
tsx | esbuild | no (strip only) | yes | yes | tsx watch | very fast | Best dev-time choice for Node. |
Node --experimental-strip-types | Node's amaro | no (strip only) | yes | yes | node --watch | very fast | Stdlib in Node 22.6+; default-on in Node 23.6+. |
Node --experimental-transform-types | Node's amaro | no | yes | yes | node --watch | very fast | Handles enum, namespace, decorators (which strip alone cannot). |
bun | Bun's parser | no | yes | yes | bun --watch | fastest | Native; also bundles, tests, installs. |
deno run | Deno's swc | yes (default) or no (--no-check) | yes | partial | deno run --watch | fast | Sandboxed; first-class TS. |
ts-node — the mature original
ts-node is the original TypeScript runner for Node.js, in maintenance since 2015. It registers as a Node loader, invokes the actual TypeScript compiler in-process for each file, caches the output, and runs the result. It's the only runner besides tsc that performs real type-checking on the running file — useful in tight scripts but slow on cold start.
npx ts-node src/index.ts
Output:
Hello from TypeScript
By default ts-node reads your tsconfig.json for compiler options and respects paths mapping. You can pass options inline:
npx ts-node --transpile-only --compiler-options '{"strict":false}' src/index.ts
Output:
Hello from TypeScript
For ESM projects ("type": "module" in package.json), use --esm or the ts-node/esm loader:
npx ts-node --esm src/main.ts
# or
node --loader ts-node/esm src/main.ts
Output:
Server ready on http://localhost:3000
Speed up ts-node significantly by enabling SWC as the transpiler — it skips type-checking entirely but compiles 20-50x faster:
// tsconfig.json
{
"ts-node": {
"swc": true,
"transpileOnly": true
}
}
npm install -D @swc/core @swc/helpers
npx ts-node src/index.ts
Output:
Hello from TypeScript
tsx — the recommended dev-time runner
tsx ("TypeScript Execute") is built on esbuild and aims to be the modern replacement for ts-node. It strips types instead of type-checking — about 100× faster on cold start. It handles .ts, .tsx, .mts, .cts, and JSX out of the box, supports CJS and ESM transparently, and ships its own watch mode.
npx tsx src/index.ts
Output:
Hello from TypeScript
Watch mode re-runs the file when any imported module changes:
npx tsx watch src/server.ts
Output:
[tsx] Restarting due to change in src/server.ts
Server ready on http://localhost:3000
tsx can be used as a Node loader for cases where you need it to coexist with other Node flags (debuggers, --inspect-brk, etc.):
node --import tsx src/index.ts
# or (ESM-only)
node --import tsx/esm src/index.ts
Output:
Hello from TypeScript
For shebangs in CLI scripts:
#!/usr/bin/env -S npx tsx
console.log("Run me directly");
chmod +x script.ts && ./script.ts
Output:
Run me directly
Node 22.6+ --experimental-strip-types
Since Node 22.6, the runtime itself can strip TypeScript annotations in-process without any package. The implementation lives in amaro (a TC39 type-stripping library written by the Node team). In Node 22, you need the flag; in Node 23.6+, type-stripping is on by default for .ts files.
node --experimental-strip-types src/index.ts
Output:
(node:1234) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Hello from TypeScript
Suppress the warning with NODE_NO_WARNINGS=1 for cleaner output:
NODE_NO_WARNINGS=1 node --experimental-strip-types src/index.ts
Output:
Hello from TypeScript
Node's strip mode only erases syntax — it can't compile features that need code generation. enum, namespace, parameter properties (constructor(public x: number)), and decorators all fail unless you also pass --experimental-transform-types, which adds esbuild-style downlevelling:
node --experimental-transform-types src/with-enum.ts
Output:
RED 0
GREEN 1
BLUE 2
Node 23.6+ runs .ts files with no flag. Watch mode pairs cleanly with strip-types:
node --watch --experimental-strip-types src/server.ts
Output:
Server ready on http://localhost:3000
Completed running 'src/server.ts'
Node's stripper does not read your
tsconfig.json. It just erases the syntax it knows about and runs the result.pathsmapping,targetdownlevelling, and JSX are all out of scope — for those features you still need a real transpiler (tsx, esbuild, bun).
Bun — native TypeScript
Bun parses TypeScript and JSX directly with its own engine — no flag, no warning, no extra package. The transpile + run round-trip happens in microseconds because Bun is a single binary written in Zig.
bun run src/index.ts
# or just
bun src/index.ts
Output:
Hello from TypeScript
Watch mode is built in:
bun --watch src/server.ts
Output:
[bun] Restarted server.ts
Server ready on http://localhost:3000
Hot reload (re-runs without losing process state, for long-lived servers):
bun --hot src/server.ts
Output:
[bun] Hot reloaded server.ts
Bun reads tsconfig.json for paths mapping but ignores target / module / lib — Bun runs the latest JS regardless. Like Node's stripper, Bun does not type-check; bun --check src/index.ts is reserved for future use.
Deno — TypeScript as a first-class input
Deno was designed around TypeScript: every script is type-checked by default, runs in a sandbox, and resolves dependencies from URLs or JSR. Skip the type-check with --no-check for faster iteration; flip on full check with deno check.
deno run --allow-net src/server.ts
Output:
Listening on http://0.0.0.0:8000
Watch mode and --no-check together yield a development loop comparable to tsx:
deno run --watch --no-check --allow-net src/server.ts
Output:
Watcher Process started.
Listening on http://0.0.0.0:8000
File change detected! Restarting!
Listening on http://0.0.0.0:8000
Standalone type-check without running:
deno check src/index.ts
Output:
Check file:///repo/src/index.ts
Watch mode for servers
Hot-reloading a TypeScript server is the canonical "what's my dev script" question. The four pragmatic answers:
# tsx — simplest, fast, handles ESM and CJS
npx tsx watch src/server.ts
# nodemon + tsx — broader file-watching glob support
npx nodemon --watch src --ext ts,tsx --exec tsx src/server.ts
# Node 22.6+ — no extra dependency
node --watch --experimental-strip-types src/server.ts
# Bun — built-in hot reload preserves process state
bun --hot src/server.ts
Output:
[tsx] Restarting due to change in src/server.ts
Server ready on http://localhost:3000
tsx watch rebuilds whenever any imported file (including transitively-imported ones) changes. node --watch only watches the entry file by default — pass --watch-path=src to expand the scope.
A complete dev script in package.json
A typical package.json for a TypeScript server combines a type-strip runner for dev with tsc --noEmit for type-checking and tsc (or tsup/bun build) for production builds.
{
"name": "@example/server",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint src"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.4.5",
"vitest": "^2.0.0"
}
}
Run the dev server:
npm run dev
Output:
[tsx] watching src/server.ts
Server ready on http://localhost:3000
Type-check in CI without running anything:
npm run typecheck
Output:
(no output — exit code 0)
ESM vs CJS gotchas
The single biggest source of trouble across runners is module format. ESM (import / export) and CJS (require / module.exports) need different loader hooks and different file extensions, and TypeScript on top adds the .mts / .cts distinction.
package.json "type": "module" -> .ts is ESM
package.json "type": "commonjs" -> .ts is CJS
.mts file -> always ESM
.cts file -> always CJS
For ESM projects under module: "NodeNext", every relative import in your source must end in .js even though the source is .ts — Node resolves the compiled path, not the source:
// CORRECT — .js extension required
import { greet } from "./greet.js";
// WRONG — Node will fail at runtime
import { greet } from "./greet";
ts-node and tsx both honour this rule. Bun, Deno, and Node's strip-types mode are more lenient with extensions, but writing portable code means following the rule everywhere.
Performance comparison
A quick way to feel the speed gap — time a minimal console.log script across runners:
echo 'console.log("hi")' > /tmp/h.ts
time npx ts-node /tmp/h.ts
time npx tsx /tmp/h.ts
time node --experimental-strip-types /tmp/h.ts
time bun /tmp/h.ts
time deno run --allow-read /tmp/h.ts
Output:
hi
real 0m0.892s (ts-node)
hi
real 0m0.121s (tsx)
hi
real 0m0.068s (node --experimental-strip-types)
hi
real 0m0.024s (bun)
hi
real 0m0.142s (deno)
ts-node's cold start cost is the type-checker spinning up. tsx and Node's stripper are within a factor of two of each other. Bun's number reflects native parsing in a Zig binary with no JS startup overhead. None of these include actual workload — once the script does real work, the gap narrows.
When to pick which
Pragmatic decision tree:
- Dev loop for a Node app →
tsx watch. Fast, ESM-friendly, no config. - Production runtime, gradual adoption →
tscbuild +node dist/. Most predictable. - Existing project on Node 22.6+, minimal deps →
node --experimental-strip-types. No new package. - Greenfield, want batteries included →
bun. Replaces npm, tsc, vitest, webpack at once. - Security or zero-config TS matters →
deno. Sandboxed by default. - You actively rely on
ts-nodefeatures (REPL, custom hooks, certain frameworks) → keepts-node. It still works; just slower. - Need to ship a CLI as a single binary →
bun build --compileordeno compile.
Common pitfalls
- Forgetting
--esmwithts-node— in an ESM project ("type": "module") you'll getUnknown file extension ".ts". Fix: pass--esmor usetsx. tsxdoesn't readtsconfig.jsonfor type-checking — it readspaths,jsx, andtargetbut never reports type errors. Runtsc --noEmitseparately.- Node's
--experimental-strip-typesfailing onenum— strip alone can't emit code. Switch to--experimental-transform-typesor move the enum to a constant-as-const object. bunignorestsconfigtarget settings — Bun runs the latest JS regardless. If you need ES2017 output for an old runtime, usetsc.- Watch mode misses transitively imported files —
node --watchonly re-runs on changes to the entry; pass--watch-path=srcor usetsx watch. ts-nodeand a bundler disagreeing aboutpaths—tsconfig-pathsregisters the mapper forts-node; Vite/esbuild have their own. Make sure both are configured the same way.- CJS-only dependency with no ESM build — under
tsxwith ESM, you may needawait import("legacy-pkg")instead of a top-levelimport. Check the package'sexportsfield. - Mixing
.tsand.ctsin one directory — Node treats them differently. Bun and Deno are more forgiving; ts-node respects the extension. Pick one extension per directory. - Stale
node_modules/.cache/tsxafter dependency updates —tsxcaches by file hash; usually fine, but arm -rf node_modules/.cache/tsxclears any weirdness. #!/usr/bin/env tsxin a published CLI — works locally withtsxinstalled globally; breaks for end users. Either build to JS before publishing or use thebinfield with a transpiler.
Real-world recipes
Hot-reloading TypeScript API server
Use tsx watch for the inner loop; pair with tsc --noEmit in a pre-commit hook and CI to enforce types.
// package.json
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsc"
}
}
// src/server.ts
import { createServer } from "node:http";
const server = createServer((req, res) => {
res.end("hello from tsx watch");
});
server.listen(3000, () => {
console.log("listening on http://localhost:3000");
});
npm run dev
Output:
listening on http://localhost:3000
Edit the file; tsx watch restarts in <100ms.
One-off script with a shebang
For "I just want to run this .ts file like a bash script" use cases, a shebang + chmod +x is the cleanest path. The env -S form passes multiple args portably.
#!/usr/bin/env -S npx tsx
import { readFile } from "node:fs/promises";
const pkg = JSON.parse(await readFile("package.json", "utf8"));
console.log(`Project: ${pkg.name}@${pkg.version}`);
chmod +x scripts/check.ts
./scripts/check.ts
Output:
Project: @example/server@0.1.0
Production runtime on Node 22 (no tsx dep)
In a constrained environment where adding tsx isn't viable, use Node's built-in stripper for the dev loop and tsc for the production build. Zero npm devDeps beyond typescript.
{
"type": "module",
"scripts": {
"dev": "node --watch --watch-path=src --experimental-strip-types src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"devDependencies": {
"typescript": "^5.4.5",
"@types/node": "^22.5.0"
}
}
NODE_NO_WARNINGS=1 npm run dev
Output:
listening on http://localhost:3000
Completed running 'src/server.ts'
Cross-runtime CI matrix
When publishing a library that should work under Node, Bun, and Deno, a CI matrix can spot runtime-specific bugs early.
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
runner: [node-tsx, node-strip, bun, deno]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
if: matrix.runner == 'node-tsx' || matrix.runner == 'node-strip'
with: { node-version: 22 }
- uses: oven-sh/setup-bun@v2
if: matrix.runner == 'bun'
- uses: denoland/setup-deno@v2
if: matrix.runner == 'deno'
- run: |
case "${{ matrix.runner }}" in
node-tsx) npm i && npx tsx src/test.ts ;;
node-strip) node --experimental-strip-types src/test.ts ;;
bun) bun src/test.ts ;;
deno) deno run --allow-read src/test.ts ;;
esac
gh workflow run test.yml && gh run watch
Output:
node-tsx ok 1.84s
node-strip ok 0.91s
bun ok 0.42s
deno ok 1.12s
Migrate from ts-node to tsx in an existing repo
A typical migration is two lines in package.json plus dropping @swc/core if it was bolted onto ts-node. The semantics are identical for most scripts; the speed jump is noticeable on every restart.
# 1. Add tsx, leave ts-node installed for fallback
npm install -D tsx
# 2. Swap the dev script
# Before: "dev": "ts-node --esm src/server.ts"
# After: "dev": "tsx watch src/server.ts"
# 3. Run the new script
npm run dev
Output:
[tsx] watching src/server.ts
listening on http://localhost:3000
Once everything works, remove ts-node and any @types/ts-node, ts-node config blocks from tsconfig.json, and tsconfig-paths-register from your imports.