Skip to content

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

FixDevs ·

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.

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"] }).

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