Fix: tsx Not Working — ESM Imports, Watch Mode, Path Aliases, and node --import tsx
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.
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"] }).
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: 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.
Fix: Node.js Test Runner Not Working — node --test, TypeScript, Mocks, Coverage, and Watch Mode
How to fix Node.js built-in test runner errors — node --test not finding files, ESM vs CJS imports, TypeScript with --experimental-strip-types, mock.method isolation, coverage reporting, and watch mode setup.