skip to content

ts-node, tsx & Friends — Running TypeScript Without a Build Step

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.

13 min read 68 snippets deep dive

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#

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.

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 (“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'

[!NOTE] 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.

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

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