Skip to content

Fix: tsx Not Working — ESM Imports, Watch Mode, Path Aliases, and node --import tsx

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix tsx (TypeScript executor) errors — Cannot find module after upgrade, ESM .js extension required, tsconfig paths not respected, watch mode not restarting, --import vs --loader, VS Code debugger setup.

The Error

You upgrade tsx and your imports stop resolving:

import { helper } from "./helper";
// Error [ERR_MODULE_NOT_FOUND]: Cannot find module

Or running with watch never restarts on save:

$ tsx watch src/server.ts
# Edit src/handler.ts → server.ts is unchanged, nothing restarts.

Or path aliases from tsconfig.json don’t resolve:

import { db } from "@/lib/db";
// Cannot find module '@/lib/db'

Or the new node --import tsx syntax fails on older Node:

$ node --import tsx src/server.ts
node: bad option: --import

Why This Happens

tsx is a thin wrapper around esbuild that adds TypeScript transpilation to Node. It can run as:

  • A standalone binary: tsx file.ts.
  • A Node loader: node --import tsx file.ts (Node 20+) or node --loader tsx file.ts (older).
  • A tsx watch process that restarts on file changes.

Three principles that surprise newcomers:

  • No type checking. tsx strips types and runs. It doesn’t catch type errors at runtime. Use tsc --noEmit separately in CI.
  • Module resolution follows Node’s rules. ESM in Node requires explicit file extensions (./helper.js, not ./helper). tsx is more lenient than vanilla Node but the same gotchas apply if you set "type": "module" in package.json.
  • Watch mode tracks the dependency graph. It restarts when any imported file changes. If a file isn’t imported (dynamic require, file read), changes don’t trigger reload.

There’s a second layer of confusion because Node’s own ESM story has shifted underfoot. The --loader hook was experimental for years, then renamed to --experimental-loader, then partially replaced by --import-based registration in Node 20.6. Each shift changed what tsx had to do internally and what flags you pass. An error that says node: bad option: --import doesn’t really mean tsx is broken — it means your Node is too old to use the new module customization API, and tsx had to silently fall back to the legacy loader hook (or fail loudly if that’s also unavailable).

The third gotcha is identity: there are two unrelated tools called tsx. The one this article is about is the TypeScript executor from @privatenumber/tsx (often installed as tsx). The other is the JSX syntax extension used by React. Some search results conflate them; if a Stack Overflow answer talks about JSX syntax errors, it’s not the same tool.

Version History: How tsx Reached Its Current Shape

tsx as we know it grew out of a different project and matured into a Node-runtime standard alongside the rest of the ESM ecosystem:

  • 2021–2022: esbuild-runner era. Before tsx, the common esbuild-based runners were esno, ts-node-esm, and the original esbuild-runner. They handled TS transpilation but had patchy ESM, watch, and source map support.
  • tsx 1.x (early 2023) rebranded and rewrote the runner using esbuild for transpilation plus a Node loader hook for module customization. The CLI was tsx file.ts; the loader was node --loader tsx file.ts.
  • tsx 3.x (mid-2023) stabilized watch mode and CJS/ESM interop. Many “lints 0 / runs 0” issues from 2022 were fixed here. It became the de-facto default for “I just want to run a .ts file” in Node.
  • tsx 4.0 (late 2023 / early 2024) dropped Node 16 and required Node 18+. It also gained first-class support for node --import tsx (Node 20.6+ module customization API), which is the form you should prefer going forward. The watch CLI added --watch-path and improved sigint handling.
  • tsx 4.7 (2024) introduced loader hooks and a documented programmatic API for registering tsx inside your own Node process. The CLI also gained tsx --tsconfig for picking a specific tsconfig when multiple coexist.
  • tsx 4.16+ (mid–late 2024 through 2025) continued tightening source maps for stack traces, improved the Bun/Deno detection, and added smarter resolution for package.json#imports aliases.

In parallel, the alternatives have moved too. ts-node still exists and is heavily used in older projects, but its --esm mode is widely considered slower than tsx for dev. swc-node is faster than tsx in raw transpilation but historically had weaker source maps and ESM coverage. Bun runs .ts natively with no runner needed at all — if you’re on Bun, tsx is redundant. Node 22+ ships --experimental-strip-types and from Node 23.6 onward type stripping is on by default; for code that only needs type erasure (no enums, no decorators), pure Node may eventually replace tsx for many workflows. tsx still wins for transforms beyond plain stripping, dev-time path alias support, and watch mode.

Fix 1: Install and Run

npm install -D tsx
# or: pnpm add -D tsx / bun add -d tsx / yarn add -D tsx

Run a TypeScript file:

npx tsx src/server.ts

Or via package.json:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "start": "tsx src/server.ts",
    "test": "tsx --test src/**/*.test.ts"
  }
}

For Node 20+:

node --import tsx src/server.ts

This uses tsx as a Node ESM loader without the wrapper process. Faster startup, more direct stack traces.

Pro Tip: For app entry points where startup time matters, use node --import tsx. For dev with watch, use tsx watch — the wrapper handles file watching and restarts.

Fix 2: Module Resolution and File Extensions

If your package.json has "type": "module":

// In src/server.ts:
import { helper } from "./helper";  // FAILS in strict ESM
import { helper } from "./helper.js";  // Works
import { helper } from "./helper.ts";  // Also works in tsx

Node’s ESM resolution requires explicit extensions. tsx is more lenient — both .ts and .js work — but for portability, write extensions explicitly.

For TypeScript source imports, prefer .js (the runtime extension), not .ts:

// src/server.ts
import { helper } from "./lib/helper.js";  // tsx maps this to helper.ts at runtime

This is also how the TypeScript team recommends writing imports in NodeNext mode. The .js extension matches what gets emitted; tsx (and modern Node 22+ with --experimental-strip-types) resolves .js to .ts source.

In tsconfig.json:

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "verbatimModuleSyntax": true
  }
}

Common Mistake: Writing import { x } from "./helper" (no extension) and getting it to work in tsx, then breaking in production when you switch to plain Node. Use explicit extensions in source.

Fix 3: Path Aliases via tsconfig-paths

tsx doesn’t read compilerOptions.paths from tsconfig.json by default. Two options:

Option A — tsconfig-paths register:

npm install -D tsconfig-paths
node --import tsx --import tsconfig-paths/register src/server.ts

tsconfig-paths/register reads tsconfig.json and patches Node’s resolver to honor paths.

Option B — use absolute imports from package.json imports:

{
  "type": "module",
  "imports": {
    "#lib/*": "./src/lib/*.js",
    "#db": "./src/db/index.js"
  }
}
import { db } from "#db";
import { logger } from "#lib/logger.js";

Node natively supports package.json#imports. Aliases start with # and the mapping uses the same syntax as exports. tsx and node --import tsx both honor this.

Pro Tip: For new projects, prefer package.json imports over tsconfig.json paths. The former is a runtime feature that works everywhere; the latter is a TypeScript-only feature that needs a loader plug.

Fix 4: Watch Mode

tsx watch re-runs on file changes:

tsx watch src/server.ts

It re-runs when:

  • Any file in the import graph of src/server.ts changes.
  • Any file matching tsx’s watch patterns changes.

For files outside the import graph (config files, templates), pass --watch-path:

tsx watch --watch-path=./config --watch-path=./templates src/server.ts

Or in v4+:

tsx watch --watch-include="config/**" src/server.ts

For excluding noisy paths:

tsx watch --ignore="dist" --ignore="logs" src/server.ts

To control restart behavior:

tsx watch --clear-screen=false src/server.ts  # Don't clear console on restart

Common Mistake: Editing a file in a different directory and expecting tsx watch to pick it up. If your src/server.ts doesn’t import anything from that directory, tsx doesn’t watch it. Either add an import or pass --watch-path.

For sigint handling:

// src/server.ts
process.on("SIGINT", async () => {
  await server.close();
  process.exit(0);
});

tsx watch sends SIGINT on restart; handle it to close DB connections, etc.

Fix 5: --import tsx vs --loader tsx

Three Node versions, three syntaxes:

  • Node 18.18+: node --loader tsx file.ts (the loader API, now legacy).
  • Node 20.6+: node --import tsx file.ts (the module customization API).
  • Anywhere: tsx file.ts (the standalone binary).

The --import form is preferred — it’s the official Node API. --loader was experimental and is being phased out.

For older Node, you may see warnings:

ExperimentalWarning: Custom ESM Loaders is an experimental feature

Suppress with --no-warnings:

node --no-warnings --import tsx src/server.ts

Or pin Node to a version where --import is stable (20.6+).

Pro Tip: In CI scripts, prefer node --import tsx over tsx directly. The Node binary is already loaded; you skip the tsx wrapper’s startup overhead.

Fix 6: VS Code Debugger

Launch config for tsx:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug tsx",
      "runtimeExecutable": "node",
      "runtimeArgs": ["--import", "tsx"],
      "program": "${workspaceFolder}/src/server.ts",
      "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
      "console": "integratedTerminal"
    }
  ]
}

The --import form lets VS Code’s debugger attach properly. Without it, breakpoints in .ts files may not bind (the source map mapping fails).

For breakpoint reliability:

{
  "compilerOptions": {
    "sourceMap": true,
    "inlineSourceMap": false
  }
}

tsx emits inline source maps; VS Code’s source-map handling sometimes prefers external maps for .ts files in node_modules. For your own src/, inline maps work fine.

Fix 7: Type Checking Separately

tsx doesn’t type-check. Add a separate command in CI:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "typecheck": "tsc --noEmit",
    "test": "tsx --test src/**/*.test.ts",
    "build": "tsc"
  }
}

In your CI:

- run: npm run typecheck
- run: npm test

For pre-commit, use Lefthook or Husky to run tsc --noEmit (slow) on push:

# lefthook.yml
pre-push:
  commands:
    typecheck:
      run: pnpm typecheck

Pro Tip: Run tsc --noEmit --incremental for fast subsequent checks. The .tsbuildinfo file caches what’s already checked, so subsequent runs only re-check what changed.

Fix 8: ESM/CJS Interop

When importing a CommonJS module from TypeScript ESM:

// some-cjs-package only exports CJS (no "type": "module"):
import pkg from "some-cjs-package";  // Default-imports the CJS export.

// For named exports from CJS:
import { x } from "some-cjs-package";  // Works if the CJS module sets module.exports = { x } and tsx detects it.

tsx handles CJS interop more flexibly than vanilla Node. If a package works in tsx but fails in node --import tsx, the issue is usually that Node’s strict ESM resolution can’t unpack module.exports.

For libraries that ship both ESM and CJS:

// In your tsconfig.json:
{
  "compilerOptions": {
    "esModuleInterop": true,
    "moduleResolution": "Bundler"
  }
}

moduleResolution: "Bundler" is forgiving — tsx (and Vite, Rollup) handle the interop. For Node-only deployment, switch to NodeNext.

Still Not Working?

A few less-obvious failures:

  • tsx watch triggers infinite restarts. A command in the entry point writes a file that’s being watched. Either move the write out of the import path or add to --ignore.
  • __dirname is undefined. Set in tsconfig.json: "module": "CommonJS", or use ESM equivalents: import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)).
  • @types/node mismatch. Pin @types/node to match your Node major version. Mismatches give weird type errors that aren’t caught by tsx (it doesn’t type-check) but break in tsc.
  • HMR-style state preservation. tsx watch is a full restart, not HMR. State (in-memory cache, open WebSocket) is lost on restart. For HMR, use Vite or Rspack with a server plugin.
  • --inspect doesn’t pause at breakpoints. Pass --inspect-brk to pause at start: node --inspect-brk --import tsx src/server.ts.
  • Bun and tsx conflict. If you’re on Bun, you don’t need tsx — Bun runs .ts natively. Use one or the other.
  • Decorators not recognized. Old TS decorators need --experimentalDecorators and --emitDecoratorMetadata in tsconfig.json. TC39 decorators (new in TS 5.0+) work without flags.
  • Worker threads can’t load .ts. Spawn workers with tsx too: new Worker(new URL("./worker.ts", import.meta.url), { execArgv: ["--import", "tsx"] }).
  • Node 22+ --experimental-strip-types collides with tsx. If you pass both, Node strips types first and tsx tries to load the already-stripped output. Pick one: tsx for full transforms (decorators, enums, JSX), or pure Node strip-types for plain TS without TS-specific runtime features.
  • Monorepo: tsx in one workspace, ts-node in another. Path resolution differs subtly between them, especially for tsconfig.json references. Pick one runner across the monorepo, or scope per-package scripts so they never run side by side.
  • Source maps broken in production logs. tsx’s inline source maps work fine in dev, but pm2 and some log shippers strip ANSI/inline metadata and break the source map column offsets. Emit external .map files for prod runs with a separate tsc build, or use Node’s --enable-source-maps flag explicitly.

For related TypeScript and Node runtime issues, see TypeScript cannot find module, Node cannot find module, Node err module not found, and Cannot use import statement outside module.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles