Setting up Node with TypeScript Troubles
Bootstrapping a new Node.js + TypeScript project should be simple by now, right? Wrong.
Every time I start fresh, I’m reminded how boring and error-prone it is to set up manually. Add to that the fast-changing JavaScript/Node ecosystem, and suddenly half the guides out there are outdated or untested.
Following a Guide That Doesn’t Work Out of the Box
I tried to follow this LogRocket guide. Looked fine on paper, recently updated… but reality check: it doesn’t work.
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/workspaces/backend-node/src/app.js' imported from /workspaces/backend-node/src/server.ts
at finalizeResolution (node:internal/modules/esm/resolve:274:11)
...
url: 'file:///workspaces/backend-node/src/app.js'
Why is it looking for app.js
when the file is actually app.ts
?
The ESM + TypeScript Import Headache
Here’s the deal: with "type": "module"
and "moduleResolution": "nodenext"
, TypeScript forces you to include .js
file extensions in your imports, even when you’re importing .ts
files during development. Because at runtime, everything compiles to .js
.
That means in server.ts
you can’t just write:
import { createApp } from "./app.ts";
You actually need:
import { createApp } from "./app.js";
Yes, even though app.js
doesn’t exist yet. Also I tried relative imports too:
import { createApp } from "./app"
But it doesn’t work, here is the error from TypeScript’s language server:
Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './app.js'?ts(2835)
To make it work, I patched tsconfig.json
:
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
Now it runs… With .js
file imports, but with this friendly warning:
ExperimentalWarning: --experimental-loader may be removed in the future
A Monster Dev Script
The docs suggest using register()
instead. So my dev script in package.json
grew into this beast:
{
...
"dev": "nodemon --watch 'src/**/*.ts' --exec \"node --import 'data:text/javascript,import { register } from \\\"node:module\\\"; import { pathToFileURL } from \\\"node:url\\\"; register(\\\"ts-node/esm\\\", pathToFileURL(\\\"./\\\"));'\" src/server.ts",
...
}
It worked without warnings… until another deprecation warning popped up (fs.Stats
constructor in newer Node versions).
At this point, I was done.
Enter tsx
Instead of fighting ts-node
, I switched to tsx. It’s simpler, faster, and just works.
My new dev script:
{
...
"dev": "nodemon --watch 'src/**/*.ts' --exec 'tsx' src/server.ts",
...
}
Clean. Minimal. No experimental flags.
Ditch dotenv, Too
The guide also recommended dotenv
for environment variables. But since Node 20.6, you can use --env-file
out of the box. Current stable (Node 24.5) already has this baked in. No need for extra dependencies.
Final package.json
Here’s what I ended up with:
{
"name": "backend-node",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "nodemon --watch 'src/**/*.ts' --exec 'tsx --env-file=.env' src/server.ts",
"lint": "eslint 'src/**/*.ts'"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"express": "5.1.0"
},
"devDependencies": {
"@types/express": "5.0.3",
"@types/node": "24.3.0",
"eslint": "9.33.0",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"tsx": "4.20.4",
"typescript": "5.9.2"
}
}
Final Thoughts
Bootstrapping a Node + TypeScript project shouldn’t feel like fighting bosses on Nightmare difficulty. But here we are.
At least with tsx
and modern Node features, the setup is cleaner – until the next ecosystem shift breaks everything again.
Happy coding. Until it breaks again. 😉