Node.js and Typescript: Module Resolution
How to configure Typescript (TS) module resolution for Node.js
In order for TS compiler to do its job preventing runtime errors at compile time, adding tooling support in the IDE, implementing better documentation, amongst other things, there are a few things TS needs to know concerning modules:
Questions
- How does the module system load TS files? Are the TS files loaded directly or are Javascript (JS) files generated by TS files loaded?
- What kind of module does the module system expect to find, given the file name it will load and its location on disk?
- If output JS is being emitted, how will the module syntax present in these files be transformed in the output code?
- Where will the module system look to find the modules code and will the lookup succeed?
- After finding the module, what format is it in? I.E. is it a CommonJS module (.cjs), an ES module (.mjs), a TS module (.ts), or declaration file (.d.ts)?
- Does the module system allow the kind of module detected in (2) to reference the kind of module detected in (5) with the syntax decided in (3)?
- Lastly, what is being imported exactly when we reference code inside a module?
These questions come from the Typescript Compiler Options documentation. They are discussed at length there. I'd like to add some more context and examples to help solidify the concepts.
Context
Answering the questions above can best be answered in the context of the host system that consumes the output JS files (or raw TS files). The host system dictates how modules will be resolved. For example, Node.js runtime may do things a certain way, and a bundler like Webpack or Vite may do things differently as well.
Module Resolution
Runtimes and bundlers have a lot of freedom to define rules on how they handle module resolution and interoperability between module systems i.e. CommonJS, ES Modules, etc. Since these rules vary, TS must be configured to match the host system's module resolution rules. We can use the moduleResolution
compiler option to tell TS which module resolution strategy to use, as well as the module
compiler option to tell TS which module system to use. The only correct module settings for projects that intend to run in Node.js are node16 and nodenext. While the emitted JavaScript for an all-ESM Node.js project might look identical between compilations using esnext and nodenext, the type checking can differ.
So what if your development team has a node.js api that is already written in TS, and the module system is set to esnext? We just learned that the only correct module settings for projects that intend to run in Node.js are node16 and nodenext. Besides bein an official declaration from typescript that this is the way to do things, how can we convince the team to change the module setting? Especially if much of the codebase will have to be changed to support the new modules setting? Here are some common objections you might run into:
OBJECTIONS (and counterarguments)
Objection 1: "It requires too many file changes"
Counterargument 1: We can automate this with a codemod script It's a one-time migration cost for long-term benefits TypeScript's migration guide provides clear patterns to follow
Objection 2: "The baseUrl pattern is convenient and readable"
Counterargument 2: Relative paths make refactoring safer (IDE tools work better) Matches Node.js native behavior, reducing confusion We can use path aliases if needed, but with explicit configuration
Objection 3: "It's working fine now, why change it?"
Counterargument 3: TypeScript officially recommends it for Node.js projects Prevents subtle runtime bugs that TypeScript currently masks Future-proofs our codebase for Node.js updates Better error messages during development
Objection 4: "Adding .js to TypeScript imports feels wrong"
Counterargument 4:
This matches how Node.js actually runs the compiled code
Prevents confusion between development and production behavior
Makes the build output more predictable
This change may require adding .js
extensions to import statements if they're not already present. The TypeScript compiler would then enforce Node.js's stricter module resolution rules and could more accurately detect module resolution errors at compile time.
So as discussed above, the change is possible and the migration could be largely automated. Thee long-term benefits outweigh the short-term effort. This is especially important for our backend services where reliability and maintainability are crucial. Not to mention these new import statements:
- Are more explicit about file relationships
- Are more consistent with how Node.js ESM resolves modules
- Are less dependent on TypeScript-specific configuration
Not to mention these benefits:
- Better Type Safety for Node.js Features
- More accurate types for Node.js-specific APIs
- Better handling of CommonJS and ESM interop
- Stricter module resolution that matches Node.js runtime behavior
Future-Proofing
- nodenext is the recommended setting for Node.js projects
- Better aligned with Node.js ESM standards
- Prepares your codebase for future Node.js versions
This makes it easier to:
- Understand file relationships
- Refactor code
- Track dependencies
- Consistency with Node.js Standards
- Your TypeScript configuration matches how Node.js actually works
- Reduces confusion between development and production environments
- Makes it easier for new developers who know Node.js to understand the codebase
- Better Error Detection
- Catches module resolution errors at compile time rather than runtime
- More accurate error messages about missing or incorrect imports
- Prevents common mistakes with file extensions and paths
So not a bad idea to make the change.