🚀 Executive Summary
TL;DR: Vitest tests often fail due to Node.js ESM requiring explicit “.js” extensions for local imports, causing MODULE_NOT_FOUND errors. This can be resolved by configuring package.json for ESM, tsconfig.json with nodenext or bundler module resolution, or vite.config.ts with resolve.extensions to allow extensionless imports.
🎯 Key Takeaways
- Node.js ESM’s default behavior requires explicit file extensions (e.g., .js, .ts) for local imports, leading to
MODULE\_NOT\_FOUNDerrors in Vitest tests if omitted. - The most robust solution involves declaring
”type”: “module”inpackage.jsonand setting”module”: “nodenext”and”moduleResolution”: “nodenext”intsconfig.jsonfor holistic modern ESM compatibility. - A targeted fix for Vitest is to configure
resolve.extensionsinvite.config.tsorvitest.config.ts, explicitly listing extensions Vite should try for module resolution. - For bundler-centric projects, setting
”moduleResolution”: “bundler”intsconfig.jsonaligns TypeScript’s import understanding with how bundlers typically resolve modules, often combined with”allowImportingTsExtensions”: true. - The optimal solution depends on project context: full ESM for new projects,
resolve.extensionsfor quick Vitest fixes, orbundlerresolution for web applications heavily relying on a bundler.
Navigating module resolution in modern JavaScript and TypeScript projects can be a headache, especially when tooling like Vitest adds another layer. If you’re tired of manually appending .js to every local import in your Vitest tests, this guide is for you. We’ll explore the common causes and provide actionable solutions to streamline your development workflow.
Problem Symptoms: The “.js” Extension Frustration
You’re writing modern JavaScript or TypeScript, aiming for clean, extensionless imports. However, when you run your Vitest tests, you encounter errors that look something like this:
Error: Cannot find module './my-utility' imported from '.../src/test.ts'-
MODULE_NOT_FOUNDwhen trying to import a local file without its.jsor.tsextension. - Vitest tests fail unless you explicitly change
import { foo } from './my-module'toimport { foo } from './my-module.js'orimport { foo } from './my-module.ts'.
This problem primarily stems from how Node.js resolves ESM modules, requiring explicit extensions for local file imports. While bundlers often abstract this away for web applications, testing environments (like Vitest, which runs on Node.js) need careful configuration to achieve that same seamless experience.
Solution 1: Embracing Modern ESM with Node.js & TypeScript (nodenext / node16)
The most robust and recommended long-term solution is to fully align your project with modern Node.js ESM practices. This involves configuring your package.json and tsconfig.json to tell Node.js and TypeScript exactly how to handle modules.
Concept
By declaring your project as an ESM module in package.json and instructing TypeScript to use Node.js’s native ESM resolution (nodenext or node16), you create a consistent environment where extensionless imports often resolve as expected. Vitest, leveraging this environment, benefits directly.
Configuration Example
package.json:
{
"name": "my-project",
"version": "1.0.0",
"type": "module", // Crucial: Declares the package as an ES module
"scripts": {
"test": "vitest"
},
"devDependencies": {
"vitest": "^1.0.0",
"typescript": "^5.0.0",
"@types/node": "^20.0.0"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "es2020",
"module": "nodenext", // Or "node16": Tells TypeScript to emit ESM compatible with Node.js
"moduleResolution": "nodenext", // Or "node16": Tells TypeScript to resolve modules like Node.js ESM
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"lib": ["es2020", "dom"],
"jsx": "react-jsx",
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "vitest.config.ts"],
"exclude": ["node_modules"]
}
Explanation
-
"type": "module"inpackage.json: This is the fundamental switch that tells Node.js (and by extension, Vitest) to treat.jsfiles as ES modules by default. It changes how Node.js resolves imports, making it more aligned with browser-like ESM behavior. -
"module": "nodenext"/"node16"and"moduleResolution": "nodenext"/"node16"intsconfig.json: These settings instruct the TypeScript compiler to produce ESM output that conforms to Node.js’s native module resolution algorithm. This means TypeScript will understand thatimport 'my-module'should look formy-module.js,my-module/index.js, etc., and apply Node.js’s specific rules for path resolution, including handling imports without explicit extensions for packages (but still requiring them for local files unlessresolve.extensionsis used). However, when combined withtype: “module”, the module resolution becomes more consistent.
Caveats
- Transitioning to full ESM can be complex if your project heavily relies on CommonJS (CJS) dependencies or has a mixed codebase. You might need to use dynamic
import()for CJS modules or use tools likeesmfor mixed environments. - This solution affects your entire project, not just Vitest. Ensure all parts of your application are compatible with ESM.
Solution 2: Direct Vite/Vitest Resolver Configuration
If a full ESM migration (Solution 1) is not immediately feasible or desired, you can directly instruct Vite (which Vitest uses) on how to resolve file extensions. This is a more targeted approach specifically for your testing environment.
Concept
Vite provides a resolve.extensions option in its configuration. By explicitly listing the extensions Vite should try when an import path doesn’t specify one, you can allow extensionless imports within your Vitest tests.
Configuration Example
vite.config.ts or vitest.config.ts:
// vite.config.ts or vitest.config.ts
import { defineConfig } from 'vite';
import { configDefaults } from 'vitest/config'; // Import if creating vitest.config.ts
export default defineConfig({
resolve: {
// Specify the extensions to be tried for modules without a file extension.
// Order matters: Vite will try these in order.
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
},
test: { // Vitest specific configuration (if using vitest.config.ts)
environment: 'jsdom', // or 'node'
globals: true,
exclude: [...configDefaults.exclude, 'e2e/*'],
// ... other Vitest configurations
},
});
Explanation
-
resolve.extensions: This array tells Vite’s resolver to automatically append these extensions (in order) when it encounters an import path without a file extension. For example,import { func } from './utils'will first try to resolve to./utils.mjs, then./utils.js,./utils.ts, and so on. - This configuration is specific to Vite’s bundler and development server, meaning it directly impacts how Vitest resolves modules during test execution.
Caveats
- This solution primarily addresses module resolution within Vite/Vitest. It doesn’t necessarily fix issues if you have other tooling (like a separate TypeScript compilation step without a bundler) that might still require explicit extensions.
- Ensure the order of extensions in the array matches the common extensions in your project to optimize resolution performance.
Solution 3: Bundler-Oriented TypeScript Configuration (moduleResolution: “bundler”)
For projects tightly coupled with a bundler (like Vite for web applications), configuring TypeScript to emulate a bundler’s module resolution can be highly effective. This approach is distinct from Node.js native ESM (nodenext) as it focuses on the bundler’s perspective.
Concept
When TypeScript’s moduleResolution is set to "bundler", it interprets imports similarly to how popular bundlers (like Webpack, Rollup, Vite) would. Bundlers are generally more forgiving about extensionless imports and often handle them seamlessly. Combining this with allowImportingTsExtensions further streamlines the development experience for TypeScript files.
Configuration Example
tsconfig.json:
{
"compilerOptions": {
"target": "es2020",
"module": "esnext", // Target latest ES modules syntax
"moduleResolution": "bundler", // Key: Emulate bundler's module resolution
"allowImportingTsExtensions": true, // Allows imports like 'import { foo } from "./bar.ts";'
"noEmit": true, // Often used with bundlers, as they handle compilation
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"lib": ["es2020", "dom"],
"jsx": "react-jsx",
"rootDir": "src"
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "vitest.config.ts"],
"exclude": ["node_modules"]
}
Explanation
-
"moduleResolution": "bundler": This is a powerful setting that tells TypeScript to use a resolution strategy that mirrors how bundlers typically operate. Bundlers are designed to find modules efficiently across various extensions and path aliases without strict adherence to Node.js’s specific ESM resolution rules, making extensionless imports more likely to succeed. -
"allowImportingTsExtensions": true: This allows you to import.tsfiles directly using their.tsextension in your source code (e.g.,import { util } from './util.ts'). While not directly related to *removing* the extension, it’s a common companion for bundler setups, as the bundler itself (or Vite/Vitest) will transpile these. In practice, withmoduleResolution: "bundler", you can often still omit the.tsextension for local TypeScript files, and the bundler resolution will pick them up. -
"noEmit": true: Often used in projects where a bundler (like Vite) handles the actual compilation and bundling, meaningtscitself doesn’t need to output JavaScript files.
Caveats
- This solution is most effective for projects where a bundler is the primary method of producing deployable code. If your project involves direct Node.js execution of compiled TypeScript output without a bundler,
nodenext/node16might be more suitable. - While
moduleResolution: "bundler"is permissive, ensuring yourvite.config.tsalso has appropriateresolve.extensions(as in Solution 2) can provide an additional layer of robustness.
Comparison Table: Choosing the Right Solution
| Feature | Solution 1: Node.js ESM & nodenext |
Solution 2: Vite/Vitest resolve.extensions |
Solution 3: TypeScript bundler Resolution |
|---|---|---|---|
| Primary Focus | Holistic modern ESM compatibility across Node.js and TypeScript. | Directly configures how Vite/Vitest resolves file extensions. | Aligns TypeScript resolution with common bundler behavior. |
| Impact Scope | Entire Node.js project (runtime & tooling). | Primarily affects Vite/Vitest’s module resolution. | Primarily affects TypeScript’s understanding of imports and bundler integration. |
| Configuration Files |
package.json, tsconfig.json
|
vite.config.ts or vitest.config.ts
|
tsconfig.json |
| Best For | New ESM-first Node.js projects, libraries, or applications aiming for future compatibility. | Quick fix for Vitest tests, or when package.json type cannot be changed easily. |
Web applications primarily using a bundler (like Vite) and TypeScript. |
| Potential Side Effects | May require adapting CJS dependencies; stricter Node.js environment. | Only addresses Vitest resolution, not other tooling (e.g., standalone tsc compilation). |
Often used with noEmit: true, relying on bundler for output. Less ideal for direct node execution of TS output. |
| Ease of Implementation | Moderate (can involve wider project changes). | Easy (focused configuration). | Moderate (requires understanding of bundler interaction). |
Conclusion
The choice of solution depends largely on your project’s architecture and future goals. For greenfield projects or those ready to fully embrace modern JavaScript, Solution 1 (Node.js ESM & nodenext) offers the most holistic and future-proof approach. If you need a quick, targeted fix for Vitest tests without broader project changes, Solution 2 (Vite/Vitest resolve.extensions) is your best bet.
Finally, for web applications where a bundler is central to your workflow, Solution 3 (TypeScript bundler Resolution) provides an elegant way to harmonize TypeScript’s understanding of modules with your bundler’s capabilities. Often, a combination of these solutions (e.g., Solution 1 and 2, or Solution 3 and 2) yields the most robust results. By carefully configuring your module resolution, you can eliminate the frustrating .js extension requirement and enjoy a smoother development experience.

Top comments (0)