Resolving Module Not Resolved Errors in NodeJS/TS in 2024
How a file extension ruined my evening and doesn't have to ruin yours.
The Problem
'/usr/app/dist/database/mongoutil' was being imported in my '/usr/app/dist/index.js' file and NodeJS was throwing the following error:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/usr/app/dist/database/mongoutil' imported from /usr/app/dist/index.js
at new NodeError (node:internal/errors:405:5)
at finalizeResolution (node:internal/modules/esm/resolve:224:11)
at moduleResolve (node:internal/modules/esm/resolve:836:10)
url: URL {},
code: 'ERR_MODULE_NOT_FOUND'
Identifying The Cause
After ensuring there were no typos or incorrect paths I discovered that it boiled down to a discrepancy between how TypeScript handles relative paths and how Node.js interprets them when running JavaScript files.
Key Point: TypeScript resolves modules without file extensions, and linting will actively push you to use relative paths by default without the configuration property allowImportingTsExtensions: true, but Node.js requires explicit extensions. In my case, TypeScript was omitting the '.js' extension in the compiled JavaScript, leading to Node.js being unable to locate the module.
The (Definitively Hacked) Solution
Here is my old package.json with dependencies removed for brevity.
{
"type": "module",
"scripts": {
"compile": "tsc",
"start": "npm run compile ; node ./dist/index.js"
},
"dependencies": {},
"devDependencies": {}
}
Here is the updated package.json.
{
"type": "module",
"scripts": {
"compile": "tsc",
"start": "npm run compile ; node --experimental-specifier-resolution=node --loader ts-node/esm ./dist/index.js \n"
},
"dependencies": {
"ts-node": "^10.9.2"
},
"devDependencies": {}
}
You'll likely immediately notice the cleverness and simplicity of this solution. Alas, if it were only so.
--experimental-specifier-resolution=node the flag in the start script instructs Node.js to use the same module resolution algorithm as TypeScript, and --loader ts-node/esm requires the ts-node package to be installed and instructs Node.js to use the ts-node loader to resolve the module.
In truth, it's more complicated than that. If you read the discussion, you'll find their defence stems from not wanting to add a feature that might (very) slightly fall outside of the original scope of TypeScript. Specifically, that it's one of their 'founding guidelines' to 'only erase types'.
Under the hood the fix provided ignores the file extension in the import statement. It's horrible. It has also been tested with Node v20.8.0.
Sadly, this isn't just a mere quirk. It has some pretty major security implications. If you are using a package that has a malicious file with the same name as a package you are importing, Node.js will happily import the malicious file instead of the package you intended to import. Fun.
Fun fact: This is the same reason why you should never use the 'eval' function in JavaScript. :)))
The (Definitively Better) Solution
- Complain to the dev's, and hope they don't lock your issue and tell you to go away;
- Use a bundler like Webpack or Rollup, they will handle the extensions for you;
- Write your own loader that adds the extensions for you, much like the ts-node loader but fast and without the security implications;
- Use the .js extension in your import statements in /dist/, and don't accidentally overwrite them when using tsc.