Module, ModuleResolution and Target in NodeJS
I did (A LOT) of reading and testing out various config options in my local machine to understand how module,moduleResolution and target configurations in tsconfig.json work.
First let us setup some terminology and explanations to understand the terms involved:
Simplest point to understand: Typescript is NOT a host
A host is the runtime where JS executes (NodeJS, browser) or a bundler that transforms the output of typescript
In any project, the first question about modules we need to answer is what kinds of modules the host expects, so TypeScript can set its output format for each file to match.
The module compiler option provides this information to the compiler. Its primary purpose is to control the module format of any JavaScript that gets emitted during compilation
ECMAScript specification defines how to parse and interpret import and export statements.
But it doesn’t specify the actual algorithm of how an import specifier (”./path/to/module” or “node-fetch”, “semver”) is resolved into a module
This also clarifies why TypeScript doesn’t modify import specifiers during emit: the relationship between an import specifier and a file on disk (if one even exists) is host-defined, and TypeScript is not a host.
The module to import from (example: “node-fetch” or something relative like ”./../shared” or ”./../shared/logger.js”)
I tested a project with both local and non-local import specifiers. The most important config option was the type option package.json, which changes how NodeJS interprets the module type (ESM vs Common JS ) of .js files. I have written a summary of how my tsc step works with the given config values for module, moduleResolution and target
Node Version used : 20.6.1 (on macOS)
No type: module in package.json
.cjs files are considered CommonJS modules.
.mjs files are considered ESM modules.
.js files are considered CommonJS modules.
| target | module | moduleResolution | result of tsc | output format |
|---|---|---|---|---|
| es2022 | None | None | Error: cant resolve modules | |
| es2022 | nodenext | nodenext | works[1] | commonjs modules |
| es2022 | commonjs | None | works[2] | common js modules |
| es2022 | es2022 | Nonde | doesn’t work (can’t resolve modules in node_modules) | |
| es2022 | es2022 | node16 | doesn’t work (moduleResultion must match module) |
[2] works only if esModuleInterop is also set to True, otherwise runs into problems
both [1] and [2] can use await syntax
both [1] and [2] have CommonJs module syntax in the emitted code:
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
....
....
....
exports.default = fetchInformation
With type: module in package.json
all relative import specifiers (example: import { logger } from "./../shared/lib/logger-wrapper.js";) MUST always have the suffix (.js .mjs) present in the import specifier, else Node will refuse to import the module.
| target | module | moduleResolution | result of tsc | output format |
|---|---|---|---|---|
| es2022 | None | None | Error: can’t resolve modules | |
| es2022 | nodenext | nodenext | doesn’t work | |
| es2022 | commonjs | None | compiles but fails at runtime[2] | common js modules |
| es2022 | es2022 | None | doesn’t work (can’t resolve modules in node_modules) | |
| es2022 | es2022 | node16 | doesn’t work (moduleResultion must match module) |
.cjs files are considered common js modules.
.mjs files are considered esm modules
.js files are considered esm modules.
[2] tsc compiles successfully but code fails at runtime when running use node /path/file.js
with the following error message:
ReferenceError: exports is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension and '/path/to/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
To use Typescript code with type: module, you can specify the import specifier for a relative module, say, ./../shared/lib/logger-wrapper.ts as ./../shared/lib/logger-wrapper.js. tsc seems to pick it up compile successfully
Also, In Typescript, module specifiers such as the ./myfile.js in import {a} from "./myfile.js" are never transformed ! This means that your transpiled code with the .js suffix in the import specifier should work on a NodeJS runtime correctly.
The Reason why this is so, is simple: The resolution of module specifier is host specific (host: browser, Node runtimer, bundler etc). Since TS is not a host, it leaves the import statement untouched (IMO, so much pain could have been avoided if TS could do this)