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.

bash
# 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:

bash
npx ts-node --version
npx tsx --version
node --version
bun --version
deno --version

Output:

text
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.

bash
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

ToolStripper backendType-check?ESMCJSWatch modeCold startNotes
tscselfyes (full)yesyes--watchslowOfficial emit, required for .d.ts files.
ts-nodeTypeScript compileryes (default) or no (--transpile-only)partial (--esm)yesvia nodemonslow (with check) / medium (without)Most config knobs; legacy choice.
ts-node (SWC)SWCnopartialyesvia nodemonfastAdd swc: true to tsconfig's ts-node block.
tsxesbuildno (strip only)yesyestsx watchvery fastBest dev-time choice for Node.
Node --experimental-strip-typesNode's amarono (strip only)yesyesnode --watchvery fastStdlib in Node 22.6+; default-on in Node 23.6+.
Node --experimental-transform-typesNode's amaronoyesyesnode --watchvery fastHandles enum, namespace, decorators (which strip alone cannot).
bunBun's parsernoyesyesbun --watchfastestNative; also bundles, tests, installs.
deno runDeno's swcyes (default) or no (--no-check)yespartialdeno run --watchfastSandboxed; 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.

bash
npx ts-node src/index.ts

Output:

text
Hello from TypeScript

By default ts-node reads your tsconfig.json for compiler options and respects paths mapping. You can pass options inline:

bash
npx ts-node --transpile-only --compiler-options '{"strict":false}' src/index.ts

Output:

text
Hello from TypeScript

For ESM projects ("type": "module" in package.json), use --esm or the ts-node/esm loader:

bash
npx ts-node --esm src/main.ts
# or
node --loader ts-node/esm src/main.ts

Output:

text
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:

jsonc
// tsconfig.json
{
  "ts-node": {
    "swc": true,
    "transpileOnly": true
  }
}
bash
npm install -D @swc/core @swc/helpers
npx ts-node src/index.ts

Output:

text
Hello from TypeScript

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.

bash
npx tsx src/index.ts

Output:

text
Hello from TypeScript

Watch mode re-runs the file when any imported module changes:

bash
npx tsx watch src/server.ts

Output:

text
[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.):

bash
node --import tsx src/index.ts
# or (ESM-only)
node --import tsx/esm src/index.ts

Output:

text
Hello from TypeScript

For shebangs in CLI scripts:

typescript
#!/usr/bin/env -S npx tsx
console.log("Run me directly");
bash
chmod +x script.ts && ./script.ts

Output:

text
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.

bash
node --experimental-strip-types src/index.ts

Output:

text
(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:

bash
NODE_NO_WARNINGS=1 node --experimental-strip-types src/index.ts

Output:

text
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:

bash
node --experimental-transform-types src/with-enum.ts

Output:

text
RED 0
GREEN 1
BLUE 2

Node 23.6+ runs .ts files with no flag. Watch mode pairs cleanly with strip-types:

bash
node --watch --experimental-strip-types src/server.ts

Output:

text
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. paths mapping, target downlevelling, 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.

bash
bun run src/index.ts
# or just
bun src/index.ts

Output:

text
Hello from TypeScript

Watch mode is built in:

bash
bun --watch src/server.ts

Output:

text
[bun] Restarted server.ts
Server ready on http://localhost:3000

Hot reload (re-runs without losing process state, for long-lived servers):

bash
bun --hot src/server.ts

Output:

text
[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.

bash
deno run --allow-net src/server.ts

Output:

text
Listening on http://0.0.0.0:8000

Watch mode and --no-check together yield a development loop comparable to tsx:

bash
deno run --watch --no-check --allow-net src/server.ts

Output:

text
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:

bash
deno check src/index.ts

Output:

text
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:

bash
# 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:

text
[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.

jsonc
{
  "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:

bash
npm run dev

Output:

text
[tsx] watching src/server.ts
Server ready on http://localhost:3000

Type-check in CI without running anything:

bash
npm run typecheck

Output:

text
(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.

text
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:

typescript
// 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:

bash
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:

text
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 apptsx watch. Fast, ESM-friendly, no config.
  • Production runtime, gradual adoptiontsc build + node dist/. Most predictable.
  • Existing project on Node 22.6+, minimal depsnode --experimental-strip-types. No new package.
  • Greenfield, want batteries includedbun. Replaces npm, tsc, vitest, webpack at once.
  • Security or zero-config TS mattersdeno. Sandboxed by default.
  • You actively rely on ts-node features (REPL, custom hooks, certain frameworks) → keep ts-node. It still works; just slower.
  • Need to ship a CLI as a single binarybun build --compile or deno compile.

Common pitfalls

  1. Forgetting --esm with ts-node — in an ESM project ("type": "module") you'll get Unknown file extension ".ts". Fix: pass --esm or use tsx.
  2. tsx doesn't read tsconfig.json for type-checking — it reads paths, jsx, and target but never reports type errors. Run tsc --noEmit separately.
  3. Node's --experimental-strip-types failing on enum — strip alone can't emit code. Switch to --experimental-transform-types or move the enum to a constant-as-const object.
  4. bun ignores tsconfig target settings — Bun runs the latest JS regardless. If you need ES2017 output for an old runtime, use tsc.
  5. Watch mode misses transitively imported filesnode --watch only re-runs on changes to the entry; pass --watch-path=src or use tsx watch.
  6. ts-node and a bundler disagreeing about pathstsconfig-paths registers the mapper for ts-node; Vite/esbuild have their own. Make sure both are configured the same way.
  7. CJS-only dependency with no ESM build — under tsx with ESM, you may need await import("legacy-pkg") instead of a top-level import. Check the package's exports field.
  8. Mixing .ts and .cts in one directory — Node treats them differently. Bun and Deno are more forgiving; ts-node respects the extension. Pick one extension per directory.
  9. Stale node_modules/.cache/tsx after dependency updatestsx caches by file hash; usually fine, but a rm -rf node_modules/.cache/tsx clears any weirdness.
  10. #!/usr/bin/env tsx in a published CLI — works locally with tsx installed globally; breaks for end users. Either build to JS before publishing or use the bin field 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.

jsonc
// package.json
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "typecheck": "tsc --noEmit",
    "build": "tsc"
  }
}
typescript
// 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");
});
bash
npm run dev

Output:

text
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.

typescript
#!/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}`);
bash
chmod +x scripts/check.ts
./scripts/check.ts

Output:

text
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.

jsonc
{
  "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"
  }
}
bash
NODE_NO_WARNINGS=1 npm run dev

Output:

text
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.

yaml
# .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
bash
gh workflow run test.yml && gh run watch

Output:

text
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.

bash
# 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:

text
[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.