cheat sheet

tsx

Package-level reference for tsx on npm — enhanced Node.js runtime for TypeScript and ESM, watch mode, ts-node replacement, and Node loader integration.

tsx

What it is

tsx (TypeScript Execute, sometimes called "TS-X") is an enhanced Node.js runtime that runs TypeScript and JSX files directly — no separate compile step, no tsc invocation. Under the hood it uses esbuild to transpile every file on demand and registers itself as a Node module loader so import of .ts, .tsx, .cts, and .mts files Just Works.

It's the de-facto successor to ts-node for new projects: faster startup (esbuild is in Go), seamless ESM support, sensible defaults, and a built-in watch mode. Vite, Astro, and tsup all use tsx-like loaders internally; tsx exposes that capability as a standalone CLI and a Node import hook.

Install

bash
# Project-local (recommended)
npm install -D tsx
pnpm add -D tsx
yarn add -D tsx
bun add -d tsx

Output: tsx binary on PATH under node_modules/.bin/tsx; ready to run tsx, tsx watch, and the tsx/esm / tsx/cjs loader entries.

bash
# Global (rare — prefer per-project)
npm install -g tsx

Output: tsx on the user PATH; updates apply globally but version-skew risk between machines.

bash
# One-off via npx
npx tsx ./script.ts

Output: downloads tsx to the npm cache if missing, then executes the script — useful for ad-hoc scripts outside any project.

Versioning & Node support

  • Current line at time of writing is 4.x. The 4.x series stabilised ESM-by-default behaviour and the node --import tsx registration model.
  • Requires Node 18.x or newer. Older Node lacks the module.register() API that tsx's loader builds on.
  • tsx itself ships as ESM but works inside both ESM and CJS host packages — the loader auto-detects which mode the file expects from the nearest package.json type field.
  • Always a dev dependency. Production should ship pre-built JavaScript; tsx is for dev, scripts, and one-off invocations.
  • SemVer respected for the CLI surface; the bundled esbuild minor bumps occasionally without a major.

Package metadata

  • Maintainer: Hiroki Osame (privatenumber)
  • Project home: github.com/privatenumber/tsx
  • Docs: tsx.is
  • npm: npmjs.com/package/tsx
  • License: MIT
  • First released: 2022
  • Downloads: multi-million weekly — has overtaken ts-node for new projects

Peer dependencies & extras

tsx has zero peer dependencies. esbuild is bundled — you don't install or upgrade it separately. The only optional partner is typescript itself (for editor type-checking; tsx does not type-check at runtime).

CompanionRole
typescriptType-checking in IDE / pre-commit (tsc --noEmit). tsx strips types, doesn't verify them.
vitestVitest can run TS tests natively, but tsx is handy for ad-hoc test-runner scripts.
nodemonPair nodemon --exec tsx src/server.ts for richer file-watch rules; or just use tsx watch.
dotenv-clidotenv -- tsx ./script.ts loads .env before execution.

Alternatives

ToolTrade-off
ts-nodeThe original. Slower (TypeScript-compiler-based), trickier ESM story, fewer defaults. Maintenance has slowed; new projects prefer tsx.
node --import (native)Node 22.6+ ships an experimental --experimental-strip-types flag — flat types stripping, no transformation. Lightweight but no JSX, no decorators, no paths.
bunBun executes TypeScript natively — fastest of all options, but commits you to the Bun runtime (different from Node).
denoSame logic — Deno runs TS natively, but it's a separate runtime with its own permissions model and import URLs.
swc-nodeSWC-based loader; comparable speed to tsx. Less polished CLI surface.
esbuild-runnerLower-level esbuild loader. Smaller, but you wire watch / ESM / source maps yourself.

Real-world recipes

Worked examples for the most common ways tsx shows up in a project.

Run a single TypeScript file

bash
# Execute and exit
tsx src/script.ts

# Pass args
tsx src/script.ts --flag --name=alice

Output:

text
Hello from script.ts (received --flag, --name=alice)

tsx resolves the file, transpiles it with esbuild, and runs it through Node. Type errors do NOT block execution — that's a feature, not a bug. Run tsc --noEmit separately for verification.

Watch mode for a dev server

bash
# Restart on any imported-file change
tsx watch src/server.ts

# Clear screen between runs
tsx watch --clear-screen src/server.ts

# Ignore extra directories
tsx watch --ignore "fixtures/**" src/server.ts

Output:

text
[tsx] Starting watch mode for src/server.ts...
Server listening on http://localhost:3000
[tsx] File changed: src/server.ts
[tsx] Restarting...
Server listening on http://localhost:3000

tsx watch traces the import graph from the entry — only files actually imported trigger restarts. Compare with nodemon, which watches by glob and restarts on unrelated changes.

ESM script with top-level await

typescript
// src/migrate.ts
import { readFile } from "node:fs/promises";
import { Client } from "pg";

const config = JSON.parse(await readFile("./config.json", "utf8"));
const db = new Client(config);
await db.connect();

const { rows } = await db.query("SELECT version()");
console.log("Postgres:", rows[0].version);

await db.end();
bash
tsx src/migrate.ts

Output:

text
Postgres: PostgreSQL 16.2 on x86_64-pc-linux-gnu

tsx always runs in ESM mode for .ts / .mts files — top-level await, import.meta.url, and dynamic import() all Just Work without "type": "module" ceremony.

Vitest companion for one-off integration scripts

json
{
  "scripts": {
    "test": "vitest run",
    "test:integration": "tsx ./scripts/integration-suite.ts",
    "seed": "tsx ./scripts/seed-db.ts"
  }
}

tsx is the cheap way to keep "real" Node scripts (seeding, migrations, one-off probes) co-located with your test suite. Vitest is for unit tests; tsx covers the long-tail of "run this TS file and exit".

Replace ts-node in package.json scripts

diff
- "dev": "ts-node-esm src/server.ts",
- "migrate": "ts-node --transpile-only ./scripts/migrate.ts",
+ "dev": "tsx watch src/server.ts",
+ "migrate": "tsx ./scripts/migrate.ts",

Removed: ts-node, typescript (if only used at runtime), --esm / --transpile-only flags. The tsconfig.json paths aliases work in tsx if you add the tsconfig-paths package — see CLI doc for the loader-args trick.

Use as a Node --import loader (manual registration)

bash
node --import tsx ./src/server.ts

Output:

text
Server listening on http://localhost:3000

Useful when you want full control over Node flags (--max-old-space-size, --inspect, --enable-source-maps) and don't want tsx's wrapper logic. From Node 20+ this is the supported way to hook a TS loader into a Node process you're already configuring.

Production deployment

tsx is not for production. Always build to JavaScript and ship the JS — tsx re-transpiles on every cold start, which is fine for dev but burns CPU at scale.

bash
# Build step (recommended)
npx tsc                       # type-check + emit .js (slowest)
# or
npx tsup src/index.ts --format esm,cjs --dts   # bundle + types

# Ship dist/
node dist/index.js

Output:

text
Server listening on http://localhost:3000

The exception: short-lived scripts, cron jobs, CI tasks where the startup cost is amortised over a long run, and the convenience of skipping a build step is worth it. For long-running servers, build first.

If you must run tsx in prod (compatibility with PM2 / Procfile not under your control), use the native node --import tsx ./dist/server.js form so source-map debugging works.

Performance tuning

tsx's steady-state perf is "fast enough" — the knobs are mostly about reducing startup latency.

Skip the watch banner

bash
TSX_TSCONFIG_PATH=tsconfig.json tsx --no-tsconfig-paths src/server.ts

Output:

text
Server listening on http://localhost:3000

tsx reads tsconfig.json from disk on every start. In a monorepo with deep configs, pre-resolve the path or pin it via TSX_TSCONFIG_PATH.

Pre-bundle for repeated invocations

For a script run thousands of times a day (cron, hot loop, CI per-PR), bundle once with esbuild and skip tsx:

bash
npx esbuild src/script.ts --bundle --platform=node --outfile=dist/script.js
node dist/script.js

Output:

text
✓ Bundled 12 files in 89ms
✓ dist/script.js (94 KiB)

Source map control

tsx emits inline source maps by default — convenient but adds size. Disable for batch scripts that don't need stack traces:

bash
tsx --no-source-maps src/script.ts

Output:

text
script completed in 142 ms

Memory in long-lived watch processes

tsx watch reuses one Node process per restart. Long-lived watch sessions accumulate transient esbuild caches — restart manually if RSS climbs past ~500 MB on a small app.

Version migration guide

tsx is comparatively young and the API has been stable since 4.0.

From → ToHighlights
3 → 4Switched default ESM detection rules; tsx esm and tsx cjs aliases unified. CLI flags --no-cache and --clear-screen standardised.
3.x stabilitySame major; minor releases mostly bumped bundled esbuild.
4.x ongoingnode --import tsx became the canonical loader form; legacy --loader tsx removed in favour of --import.

Watch the changelog for tsx's eventual 5.0 — likely to follow Node's evolution of the module-loader API (the experimental --experimental-loader flag is being phased out in favour of module.register()).

Security considerations

tsx is a developer tool — its security surface is small but real.

  1. Arbitrary code execution by design. tsx ./untrusted.ts runs the file with full Node privileges. Never run untrusted .ts files via tsx; sandbox via container, --permission (Node 22+), or a separate user.
  2. Source-map leaks. Inline source maps embed file paths from your workstation. Don't ship tsx-emitted JS to production — those paths leak through stack traces and dev-tools.
  3. tsconfig.json extends chains. tsx resolves extends recursively; a malicious package in extends could control compile flags. Pin your tsconfig dependencies (@tsconfig/node20 etc.) the same way you pin runtime deps.
  4. No type checking at runtime. tsx strips types, doesn't enforce them. A boundary that relies on type narrowing for security (e.g. "this field is 'admin' | 'user'") will accept any string at runtime. Always validate inputs with zod / valibot / manual checks.
  5. Loader hook collisions. Custom --import hooks layered on top of tsx must register after tsx, not before — registration order affects whether .ts files reach your hook at all.

Testing & CI integration

yaml
# .github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx tsc --noEmit               # type-check (tsx doesn't do this)
      - run: npx tsx ./scripts/db-up.ts     # start test infra
      - run: npm test                        # vitest / jest
      - run: npx tsx ./scripts/db-down.ts   # teardown

For monorepos, prefer pnpm exec tsx ./scripts/... so the workspace's pinned tsx version runs (not whatever's on the global $PATH).

Ecosystem integrations

tsx plays nicely with most of the modern Node ecosystem because it's "just a Node loader":

  • Vitest — Vitest's own transformer is independent; tsx is for non-test scripts. Both work side by side.
  • Vite — uses an embedded esbuild loader internally; tsx exposes the same capability as a CLI.
  • Astro — Astro's Node integration uses a tsx-style loader for .ts middleware.
  • tsup — sibling project by the same maintainer (privatenumber); pairs naturally for "tsx in dev, tsup for ship".
  • pkgroll — another privatenumber library; builds packages whose entry was developed under tsx.
  • nodemonnodemon --exec tsx src/server.ts if you need nodemon's globbing rules over tsx watch's import-graph tracking.
  • dotenvx / dotenv-cli — wrap as dotenvx run -- tsx ./script.ts to inject env before transpile.

Troubleshooting common errors

Unknown file extension ".ts"

The Node process is running without the tsx loader. Either invoke via the tsx binary (tsx ./file.ts) or pass --import tsx to your manual node invocation:

bash
node --import tsx ./file.ts

Output:

text
hello from file.ts

Cannot find module '@/utils' with TS paths

tsx does NOT resolve tsconfig.json paths aliases by default. Add the tsconfig-paths companion or @tsx/aliases loader:

bash
node --import tsx --import tsconfig-paths/register ./src/server.ts

Output:

text
Server listening on http://localhost:3000

SyntaxError: Cannot use import statement outside a module

The file is being interpreted as CJS but contains ESM syntax. Either rename to .mts, set "type": "module" in the nearest package.json, or use tsx --experimental-specifier-resolution=node.

Watch mode misses changes in monorepo siblings

tsx watch follows the static import graph. Files imported via dynamic require() or string-interpolated import() aren't tracked. Wrap with nodemon --watch ../shared for explicit globbing.

Memory leak after many restarts

esbuild caches accumulate. Kill and re-spawn tsx watch every few hours during long dev sessions, or use nodemon as a supervising parent.

When NOT to use this

  • Production servers. Build to JS — tsx re-transpiles every cold start.
  • Type-sensitive deploys. tsx strips types without checking. Pair with a tsc --noEmit pre-flight, or use a build step that does both.
  • Performance-critical hot paths. The startup overhead (Node + esbuild + loader registration) is ~50–100 ms — fine for scripts, painful in tight loops. Build a binary with esbuild --bundle or use bun directly.
  • Browser code. tsx is server-side only — it loads code via Node's module system. For browser TS use Vite / esbuild / Webpack.
  • Projects already committed to bun or deno. Both runtimes execute TypeScript natively; layering tsx on top buys nothing.

See also