Skip to Content
Course content

51: Module Resolution Strategies

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was helping a colleague migrate a legacy internal tool from CommonJS to ES Modules. He had just changed his package.json to "type": "module" and updated his tsconfig.json to "module": "NodeNext". He came to me completely baffled because his IDE was suddenly bleeding red. He had a file called auth-service.ts, and he was importing it as import { login } from './auth-service';. He told me, "The file is right there in the same folder. Why is TypeScript telling me it can't find the module?"

This is the exact moment where most developers hit a wall with module resolution. It feels like a regression because, for years, we've been trained to omit file extensions in our imports. But as TypeScript aligns more closely with how Node.js actually handles ES Modules, the rules of the game have changed.

The Shift from Node10 to NodeNext

In your tsconfig.json, the moduleResolution setting tells TypeScript exactly which algorithm to use to find the file you're trying to import. For a long time, node10 (formerly just called node) was the gold standard. It's a permissive strategy: it looks for a .ts file, then a .tsx file, then checks if there's an index.ts inside a folder of the same name. It's convenient, but it's essentially a "guess" that doesn't always match how the code actually executes in a modern runtime.

When you switch to node16 or nodenext, you're telling TypeScript to stop guessing and start following the strict rules of Node.js's ESM implementation. These strategies are tied to the module setting. If you set "module": "NodeNext", TypeScript automatically sets the resolution strategy to match. The biggest change here is that the runtime now requires explicit extensions. If you're using ESM, Node.js wants to know exactly which file it's loading without searching the file system for candidates.

The Extension Paradox

This leads to the part that feels totally wrong when you first see it: you have to import the .js extension, even though you are writing a .ts file. I know it feels like a lie. You're looking at user-model.ts on your disk, but you write import { User } from './user-model.js';.

// This works in node10, but fails in nodenext
import { validate } from './validator'; 

// This is required in nodenext
import { validate } from './validator.js'; 

Why? Because TypeScript doesn't rewrite your import paths during compilation. It assumes that since the code will eventually be executed as JavaScript, the import path should reflect the final output. If you're targeting a modern Node environment, the .js extension is mandatory for ESM. It's a bit mind-bending at first, but it's the only way to ensure your code is actually compatible with the runtime without relying on heavy bundling tools like Webpack or Vite to "fix" your paths for you.

Handling Package Exports

Beyond just file extensions, NodeNext resolution respects the exports field in a library's package.json. In the old node10 days, if a package had a main entry point, you could often reach deep into the package's folder structure—like import { internal } from 'some-lib/dist/internal/utils'. Modern resolution strategies stop this.

If a library defines an exports map, TypeScript will only let you import the specific entry points the author has explicitly exposed. If you try to import something not listed in that map, you'll get a module resolution error, even if the file physically exists in node_modules. This is actually a great feature for library authors to hide internal implementation details, but it can be a headache for you if you're relying on a poorly configured third-party package.




📋 Practical Task

Fixing "Module Not Found" in a NodeNext Migration

You have been handed a small project that is being migrated to NodeNext. The developer has already updated the tsconfig.json and package.json, but the code is now full of resolution errors. Your task is to fix the imports so the project compiles under the NodeNext strategy.

Current Project Structure:

  • package.json (contains "type": "module")
  • tsconfig.json (contains "module": "NodeNext" and "moduleResolution": "NodeNext")
  • src/index.ts
  • src/logger.ts
  • src/utils/formatter.ts

The broken src/index.ts:

import { logInfo } from './logger';
import { formatCurrency } from './utils/formatter';

logInfo(formatCurrency(100));

The broken src/logger.ts:

import { formatCurrency } from './utils/formatter';

export function logInfo(msg: string) {
    console.log(`[LOG]: ${msg}`);
}

Your Task: Rewrite the import statements in src/index.ts and src/logger.ts to satisfy the NodeNext resolution requirements. Ensure that the imports reflect the final compiled output paths.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.