Fix: tsx Not Working — ESM Imports, Watch Mode, Path Aliases, and node --import tsx
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 moduleOr 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: --importWhy 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+) ornode --loader tsx file.ts(older). - A
tsx watchprocess 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 --noEmitseparately 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"inpackage.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 wereesno,ts-node-esm, and the originalesbuild-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 wasnode --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
.tsfile” 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-pathand 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 --tsconfigfor 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#importsaliases.
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 tsxRun a TypeScript file:
npx tsx src/server.tsOr 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.tsThis 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 tsxNode’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 runtimeThis 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-pathsnode --import tsx --import tsconfig-paths/register src/server.tstsconfig-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.tsIt re-runs when:
- Any file in the import graph of
src/server.tschanges. - 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.tsOr in v4+:
tsx watch --watch-include="config/**" src/server.tsFor excluding noisy paths:
tsx watch --ignore="dist" --ignore="logs" src/server.tsTo control restart behavior:
tsx watch --clear-screen=false src/server.ts # Don't clear console on restartCommon 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 featureSuppress with --no-warnings:
node --no-warnings --import tsx src/server.tsOr 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 testFor pre-commit, use Lefthook or Husky to run tsc --noEmit (slow) on push:
# lefthook.yml
pre-push:
commands:
typecheck:
run: pnpm typecheckPro 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 watchtriggers 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.__dirnameis undefined. Set intsconfig.json:"module": "CommonJS", or use ESM equivalents:import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)).@types/nodemismatch. Pin@types/nodeto match your Node major version. Mismatches give weird type errors that aren’t caught by tsx (it doesn’t type-check) but break intsc.- 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.
--inspectdoesn’t pause at breakpoints. Pass--inspect-brkto 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
.tsnatively. Use one or the other. - Decorators not recognized. Old TS decorators need
--experimentalDecoratorsand--emitDecoratorMetadataintsconfig.json. TC39 decorators (new in TS 5.0+) work without flags. - Worker threads can’t load
.ts. Spawn workers withtsxtoo:new Worker(new URL("./worker.ts", import.meta.url), { execArgv: ["--import", "tsx"] }). - Node 22+
--experimental-strip-typescollides 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.jsonreferences. 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
pm2and some log shippers strip ANSI/inline metadata and break the source map column offsets. Emit external.mapfiles for prod runs with a separatetscbuild, or use Node’s--enable-source-mapsflag 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: pnpm Catalog Protocol Not Working — Cannot Find Catalog, Resolution Errors, and Lockfile Issues
Fix pnpm 9.5+ catalog protocol errors — Cannot find catalog default, ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC, stale lockfile state, and tool incompatibility with catalog: references in monorepos.
Fix: ESLint Flat Config Not Working — eslint.config.js, ignores, Plugins, and Migration
How to fix ESLint flat config errors — eslint.config.js not found, .eslintrc.json ignored after upgrade, ignores replacing .eslintignore, plugin object form, typescript-eslint integration, monorepo configs, and ESLINT_USE_FLAT_CONFIG.
Fix: mise Not Working — Shell Activation, .tool-versions, Plugin Install, and Python venv
How to fix mise (formerly rtx) errors — activation hook not running, tool not found after install, .tool-versions vs .mise.toml, Python venv integration, idiomatic env loading, and trust prompts.
Fix: Mongoose Not Working — Connection Options Removed, strictQuery, populate, and Lean Queries
How to fix Mongoose errors — useNewUrlParser removed, strictQuery default flip, populate returning null, lean() losing methods, discriminator setup, transaction sessions, and TypeScript Document types.