TL;DR
mlly is the UnJS module resolution utility — resolves ESM and CJS imports, finds the nearest package.json, detects module types, and provides createResolve for custom resolvers. import.meta.resolve is the built-in ESM resolution API — resolves module specifiers to file URLs, stable in Node.js 20+. resolve is the classic require.resolve algorithm as a library — synchronous path resolution, works from any directory, used by bundlers and tools. In 2026: import.meta.resolve for built-in ESM resolution, mlly for advanced resolution utilities, resolve for CJS resolution in tools.
Key Takeaways
- mlly: ~10M weekly downloads — UnJS, ESM utilities, resolves both ESM and CJS
- import.meta.resolve: built-in (Node.js 20+) — stable ESM resolution, returns file URLs
- resolve: ~50M weekly downloads — implements require.resolve algorithm, works from any basedir
- Module resolution converts specifiers ("vue") to file paths ("/node_modules/vue/dist/vue.mjs")
- ESM resolution uses
import.meta.resolve, CJS usesrequire.resolve - mlly bridges the gap with utilities that work across both module systems
The Problem
// CJS — require.resolve works great:
const vuePath = require.resolve("vue")
// → "/project/node_modules/vue/dist/vue.runtime.common.js"
// ESM — no require.resolve available:
// require.resolve("vue") // ❌ ReferenceError: require is not defined
// import.meta.resolve is the ESM equivalent:
const vuePath = import.meta.resolve("vue")
// → "file:///project/node_modules/vue/dist/vue.runtime.esm-bundler.js"
// But what if you need to resolve from a different directory?
// Or detect if a module is ESM or CJS?
// Or resolve with custom conditions?
// → That's where mlly and resolve help
mlly
mlly — ESM module utilities:
Resolve modules
import { resolve, resolvePath, createResolve } from "mlly"
// Resolve a module specifier:
const resolved = await resolve("vue", { url: import.meta.url })
// → "file:///project/node_modules/vue/dist/vue.runtime.esm-bundler.js"
// Resolve to a file path (not URL):
const filePath = await resolvePath("vue", { url: import.meta.url })
// → "/project/node_modules/vue/dist/vue.runtime.esm-bundler.js"
// Resolve with custom conditions:
const edgePath = await resolve("vue", {
url: import.meta.url,
conditions: ["edge", "worker", "import"],
})
// Create a resolver with fixed base URL:
const resolveFromProject = createResolve({ url: "/path/to/project/" })
const dep = await resolveFromProject("lodash")
Detect module type
import { isValidNodeImport, detectSyntax, getProtocol } from "mlly"
// Check if a file uses ESM or CJS:
const syntax = detectSyntax(`
import { ref } from "vue"
export default { setup() { return { count: ref(0) } } }
`)
// → { hasESM: true, hasCJS: false, isMixed: false }
const cjsSyntax = detectSyntax(`
const vue = require("vue")
module.exports = { count: 0 }
`)
// → { hasESM: false, hasCJS: true, isMixed: false }
// Check if a specifier is a valid Node.js import:
await isValidNodeImport("/path/to/module.mjs") // true
await isValidNodeImport("/path/to/module.cjs") // true
Resolve path info
import { resolvePathSync, fileURLToPath, pathToFileURL } from "mlly"
// Convert between file URLs and paths:
const url = pathToFileURL("/Users/royce/project/src/index.ts")
// → "file:///Users/royce/project/src/index.ts"
const path = fileURLToPath("file:///Users/royce/project/src/index.ts")
// → "/Users/royce/project/src/index.ts"
Find nearest package.json
import { findPackageJSON, readPackageJSON } from "mlly"
// Find the nearest package.json:
const pkgPath = await findPackageJSON(import.meta.url)
// → "/Users/royce/project/package.json"
// Find a dependency's package.json:
const vuePkgPath = await findPackageJSON("vue", { url: import.meta.url })
// → "/Users/royce/project/node_modules/vue/package.json"
ESM interop utilities
import { interopDefault, resolveImports } from "mlly"
// Handle default export interop (CJS → ESM):
const mod = await import("cjs-package")
const defaultExport = interopDefault(mod)
// Handles: { default: { default: fn } } → fn
// Handles: { default: fn } → fn
// Resolve all import specifiers in source code:
const code = `
import { ref } from "vue"
import { useRouter } from "vue-router"
`
const resolved = await resolveImports(code, { url: import.meta.url })
// Replaces "vue" and "vue-router" with resolved file URLs
import.meta.resolve (Built-in)
import.meta.resolve — built-in ESM resolution:
Basic usage
// Resolve a bare specifier:
const vuePath = import.meta.resolve("vue")
// → "file:///project/node_modules/vue/dist/vue.runtime.esm-bundler.js"
// Resolve a relative path:
const utilsPath = import.meta.resolve("./utils.js")
// → "file:///project/src/utils.js"
// Resolve a subpath export:
const vueReactivity = import.meta.resolve("vue/reactivity")
// → "file:///project/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js"
Checking if a package exists
// Check if a package is installed:
function isPackageInstalled(name: string): boolean {
try {
import.meta.resolve(name)
return true
} catch {
return false
}
}
isPackageInstalled("vue") // true (if installed)
isPackageInstalled("nonexist") // false
Limitations
import.meta.resolve:
✅ Built-in (Node.js 20+ stable)
✅ Zero dependencies
✅ Follows Node.js ESM resolution algorithm
✅ Respects package.json "exports" field
❌ Only resolves from current file's location
❌ Cannot resolve from arbitrary directories
❌ Returns file:// URLs (not paths)
❌ No CJS support (ESM only)
❌ No custom conditions parameter
❌ Synchronous (can't customize async hooks)
resolve
resolve — require.resolve as a library:
Basic usage
import resolve from "resolve"
// Synchronous resolution:
const vuePath = resolve.sync("vue", {
basedir: "/path/to/project",
})
// → "/path/to/project/node_modules/vue/dist/vue.runtime.common.js"
// Async resolution:
resolve("vue", { basedir: "/path/to/project" }, (err, resolved) => {
console.log(resolved)
})
// Promise wrapper:
import { promisify } from "node:util"
const resolveAsync = promisify(resolve)
const path = await resolveAsync("vue", { basedir: process.cwd() })
Custom resolution
import resolve from "resolve"
// Resolve from a specific directory:
const path = resolve.sync("./utils", {
basedir: "/path/to/project/src",
extensions: [".ts", ".tsx", ".js", ".jsx"],
})
// Custom module directories:
const path2 = resolve.sync("my-package", {
basedir: process.cwd(),
moduleDirectory: ["node_modules", "custom_modules"],
})
// Package filter:
const path3 = resolve.sync("some-package", {
basedir: process.cwd(),
packageFilter: (pkg) => {
// Use "module" field instead of "main":
if (pkg.module) pkg.main = pkg.module
return pkg
},
})
Path filter and resolution hooks
import resolve from "resolve"
// Path filter — transform resolved paths:
const path = resolve.sync("my-lib", {
basedir: process.cwd(),
pathFilter: (pkg, path, relativePath) => {
// Redirect imports:
if (relativePath === "./lib/index.js") {
return "./src/index.ts"
}
return relativePath
},
})
// Check if a path is a core module:
resolve.isCore("fs") // true
resolve.isCore("vue") // false
resolve.isCore("path") // true
How bundlers use resolve
// Webpack, Browserify, and other bundlers use resolve internally:
// Simplified bundler resolution:
import resolve from "resolve"
function resolveModule(specifier: string, fromFile: string) {
return resolve.sync(specifier, {
basedir: dirname(fromFile),
extensions: [".js", ".ts", ".tsx", ".jsx", ".json"],
packageFilter: (pkg) => {
// Prefer "module" or "browser" fields:
pkg.main = pkg.module || pkg.browser || pkg.main
return pkg
},
})
}
// This is why bundlers can resolve TypeScript files,
// use package.json "module" field, etc.
Feature Comparison
| Feature | mlly | import.meta.resolve | resolve |
|---|---|---|---|
| ESM resolution | ✅ | ✅ | ❌ (CJS) |
| CJS resolution | ✅ | ❌ | ✅ |
| Custom basedir | ✅ | ❌ | ✅ |
| Custom conditions | ✅ | ❌ | ❌ |
| Package.json exports | ✅ | ✅ | ⚠️ (limited) |
| Syntax detection | ✅ | ❌ | ❌ |
| URL ↔ path conversion | ✅ | ❌ | ❌ |
| Sync API | ✅ | ✅ | ✅ |
| Built-in | ❌ | ✅ (Node.js 20+) | ❌ |
| Used by | Nuxt, UnJS | Node.js core | Webpack, Browserify |
| Weekly downloads | ~10M | Built-in | ~50M |
When to Use Each
Use mlly if:
- Need ESM resolution utilities beyond import.meta.resolve
- Want to detect ESM vs CJS syntax in files
- Building tools that need to resolve from arbitrary directories
- In the UnJS ecosystem (Nuxt, Nitro, unbuild)
Use import.meta.resolve if:
- Resolving modules from the current file's location
- Want zero dependencies (built-in Node.js 20+)
- Checking if a package is installed
- Simple specifier-to-URL resolution
Use resolve if:
- Building a bundler or CJS-based tool
- Need to resolve from arbitrary base directories
- Want custom package.json field resolution (module, browser)
- Need the require.resolve algorithm as a library
Community Adoption in 2026
resolve leads at approximately 50 million weekly downloads, reflecting its foundational role in the Node.js CommonJS toolchain. Webpack, Browserify, Jest, ESLint, and dozens of other major tools use resolve internally to implement module lookup. Despite the ecosystem's shift toward ESM, resolve remains essential for CJS-based tools that must resolve modules from arbitrary directories — a capability that import.meta.resolve does not provide.
mlly reaches approximately 10 million weekly downloads, driven by its role as the UnJS module resolution utility. Every application using Nuxt, Vite's Node.js plugin APIs, or unbuild depends on mlly for ESM-aware resolution. Its utility functions — findStaticImports, parseStaticImport, resolvePath — are the building blocks for tools that need to analyze or transform ES module import graphs.
import.meta.resolve is a Node.js built-in (stable since Node.js 20) and not tracked as an npm download. It is the zero-dependency option for simple resolution needs in ESM contexts. Its primary limitation — resolving only from the current file's location, not from an arbitrary basedir — is a fundamental design constraint of the ESM resolution algorithm, not a bug to be fixed in a future version.
Migration Guide
From resolve to mlly for ESM projects
The resolve package implements the CJS require.resolve algorithm. For ESM projects on Node.js 18+, mlly provides the equivalent ESM resolution utilities:
// resolve (old — CJS algorithm)
import resolve from "resolve"
const vuePath = resolve.sync("vue", { basedir: process.cwd() })
// → "/project/node_modules/vue/dist/vue.runtime.common.js" (CJS entry)
// mlly (new — ESM algorithm, respects exports map)
import { resolvePath } from "mlly"
const vuePath = await resolvePath("vue", { url: import.meta.url })
// → "file:///project/node_modules/vue/dist/vue.runtime.esm-bundler.js" (ESM entry)
The key difference is that mlly respects the exports field in package.json (the modern ESM package entry point map), while resolve's default behavior follows the older main field.
Module Resolution in Monorepos and Build Tool Contexts
Module resolution becomes significantly more complex in monorepo environments where packages reference each other and tools must resolve imports across package boundaries.
mlly in monorepos understands pnpm's symlink structure. When package A imports from package B (a peer in the monorepo), pnpm creates a symlink in node_modules/package-b pointing to the actual source. mlly's resolvePath follows these symlinks correctly and resolves to the actual file path, not the symlink target. This matters for tools that need to watch files for changes — a watcher watching the symlink target rather than the symlink itself may not receive inotify/FSEvents notifications correctly. mlly's isNodeBuiltin, normalizeid, and sanitizeFilePath utilities are particularly useful in build tools that process import specifiers extracted from ASTs.
import.meta.resolve() has distinct behavior for bare specifiers (package names) versus relative paths. For bare specifiers, it resolves using Node.js's full resolution algorithm including exports field conditions. The conditions parameter lets you specify which export conditions to apply: import.meta.resolve('pkg', undefined, ['node', 'import']) uses the import condition, while ['node', 'require'] uses the CJS condition. This precise control makes import.meta.resolve() invaluable for tools that need to find exactly which file Node.js would load for a given import.
The resolve npm package (not mlly) is the long-established synchronous alternative used by Webpack, Babel, and many older tooling projects. It implements the CommonJS resolution algorithm and does not understand ESM exports fields. Projects migrating from CJS tooling to ESM tooling often encounter resolve as a transitive dependency — if resolve is the underlying mechanism and your package uses exports field-only entry points, the resolution will fail. Switching to mlly or import.meta.resolve() fixes these cases.
Caching considerations apply to all three approaches. Module resolution performs filesystem operations (stat calls, readdir), and in hot paths (like Vite's transform phase or a test runner's module loader), these operations should be cached. mlly encourages caching through its functional API — callers can implement their own caches. jiti (which uses mlly internally) maintains a module cache by file path. For custom tools that call resolution many times per second, measure and profile resolution calls to identify caching opportunities.
TypeScript and ESM Interoperability Considerations
The shift from CommonJS to ESM has made module resolution significantly more complex, and each of these tools occupies a distinct niche in that complexity. TypeScript's module resolution modes (bundler, node16, nodenext) now map to different resolution algorithms, and understanding which resolution algorithm a tool uses is essential for debugging import failures in complex project setups.
mlly is aware of TypeScript's dual-mode publication pattern (packages that ship both .cjs and .mjs files with separate exports map conditions). When you call mlly.resolvePath('some-package', { url }), it follows the exports field conditions for the current module type, returning the .mjs entry for ESM consumers and the .cjs entry for CJS consumers. This is why build tools like unbuild and Vite's Node.js API plugins use mlly — they need resolution that accurately reflects what the runtime would do.
The resolve package (the CJS-only tool) handles the oldest widespread use case: given a package name and a directory, find the package's main entry point following the require.resolve algorithm. This includes reading package.json's main field, checking the node_modules/ directory tree, and respecting .pnp.cjs (Yarn PnP) if present. Tools like Jest (in its legacy CJS mode), ESLint, and webpack continue to depend on resolve because they implement their own module loading on top of the resolution algorithm. For these tools, resolve's stability and its deep support for edge cases in the Node.js CJS resolution algorithm outweigh the benefits of switching to an ESM-aware tool.
Understanding the practical failure modes of each tool prevents hours of debugging. When mlly fails to resolve a package, the most common cause is a missing exports field condition — the package ships an exports map but does not include a condition that matches the current environment (e.g., the package has browser and import conditions but the tool requests node and require). When import.meta.resolve fails, it is typically because the package is not installed (bare specifier resolution requires the package to be present in node_modules) or the file URL provided as the second argument is not a valid file URL. When resolve fails, it is usually because the package uses only exports field entry points (no main field) and resolve's default mode does not read the exports field. In each case, the error message points to the resolution step that failed, making these debugging sessions tractable once you understand the resolution algorithm differences.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on mlly v1.x, Node.js 22 import.meta.resolve, and resolve v1.x.
Compare module resolution and developer tooling on PkgPulse →
See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).