Library / SDK
unjs/mlly avatar
unjs/mlly

mlly: filling in the gaps in Node's ESM support

🤝 Common ECMAScript module utils

529 stars63 forksTypeScriptMIT

At a glance

What is it?
mlly is a small utility library for Node.js that supplies the pieces the built-in module system still lacks, including a resolver that follows the ECMAScript algorithm while accepting extensionless and index imports, syntax detection that tells you whether a package is ESM or CommonJS before you dynamically import it, and a CommonJS context for ESM code. It suits anyone building tooling on top of Node's module resolution, and the detail to plan around is that several of these utilities are heuristics rather than guarantees.
Who is it for?
Adopt mlly if you are writing tooling that has to resolve or inspect modules the way Node does, because the resolver and the syntax detection cover the awkward cases that a hand-written path join does not.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 3 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What the package is for

mlly describes itself as the missing ECMAScript module utils for Node.js, and the framing in the README is about the state of the platform rather than about the library's own cleverness.

While ESM modules are evolving in the Node.js ecosystem, there are still many required features that are still experimental or missing or needed to support ESM. This package tries to fill in the gap.

That is an accurate description of the category. Node has had stable ESM for years, but the surrounding tooling story is still fragmented. A bundler author, a module author, or anyone building a plugin system has to reimplement parts of the resolver, work out whether a package is ESM or CommonJS, and handle the fact that the two systems disagree about things like extensionless imports. mlly exists so that they do not each have to.

It is part of the unjs organisation, and the dependencies give a good picture of how it works. `pathe` for cross-platform path handling, `ufo` for URL manipulation, `pkg-types` for reading package.json data, and `acorn` for parsing. The package is MIT licensed, the primary language is TypeScript, the default branch is main, and the last push was on 2026-10-20. The releases are v1.8.2 from 2026-03-19, v1.8.1 from 2026-03-04 and v1.8.0 from 2025-08-22.

The package itself is small and ships as ESM and CommonJS with type declarations, using unbuild for the build and Vitest for the tests. The exports map gives you `dist/index.mjs` for import and `dist/index.cjs` for require, and the manifest is marked with `sideEffects: false`, which is the correct flag for a library of pure functions and helps bundlers drop it entirely when nothing is used.

The recommended runtime is Node.js 14 or newer. The README also shows the two installation routes, yarn and npm, and that the package can be imported from ESM with a named import or required from CommonJS, which is the point of shipping both.

Resolution, and the options that matter

The resolution utilities are the core of the package, and they are built on the platform's own algorithm rather than a parallel implementation.

The `resolve` and `resolveSync` functions resolve a module by respecting the ECMAScript resolver algorithm, using the import-meta-resolve package, and they additionally support resolving without an extension and with an `/index` suffix in the manner of CommonJS. The first of those is the important part: the result matches what Node would do, which is the property that makes the output usable for anything that has to agree with the runtime.

The options are the part to read closely, because they are where the differences from a plain path join live.

`url` is the URL or string to resolve from, defaulting to the current working directory. `conditions` is the array of conditions used for the resolution algorithm, defaulting to `['node', 'import']`, which is the set of export conditions Node uses when resolving an ESM import. `extensions` is the array of additional extensions to check if the import failed, defaulting to `['.mjs', '.cjs', '.js', '.json']`.

So the extension list is exactly the CommonJS extensionless-import fallback, and the conditions list is the export condition set. A tool that resolves differently from Node will get these wrong, and the ability to override them is what lets a tool emulate a different environment, such as a browser bundler with different conditions.

Two variants exist for the return type. `resolvePath` and `resolvePathSync` are similar but return a path instead of a URL, using fileURLToPath, which is what you want when the result is going into a file operation rather than into an import statement.

`createResolve` creates a resolve function with defaults, which is the convenience form for when you want to resolve many things relative to the same location:

js
import { createResolve } from "mlly";

const _resolve = createResolve({ url: import.meta.url });

// file:///home/user/project/module.mjs
console.log(await _resolve("./module.mjs"));

And the README gives the ponyfill for import.meta.resolve as the motivating case, assigning a created resolve onto import.meta.resolve directly. That is a good illustration of the library's purpose: it is a set of pieces Node has not standardised yet, packaged so that a project can adopt them without each writing its own.

The feature list for resolution also names Windows path normalisation, support for resolving from multiple paths or URLs, and the fact that the utilities are exposed from the Node implementation rather than reimplemented, which is what keeps them honest.

There is one more resolution utility, `resolveImports`, which resolves all static and dynamic imports in a piece of source code with relative paths turned into full resolved paths, which is what a dependency analyser needs.

Syntax detection, and the best-effort caveat

The syntax analysis section is where the library does something that Node still does not, and it is also where the README is most careful about limits.

`isValidNodeImport` uses various syntax detection and heuristics to determine whether an import is a valid import, or is not to be imported using dynamic `import()` before hitting an error. When the result is false, you usually need to create a CommonJS require context or add specific rules to the bundler to transform the dependency.

The algorithm is spelled out in full, and reading it tells you exactly how much to trust the answer.

It checks the import protocol first: a `data:` import returns true, and an import that is not `node:`, `file:` or `data:` returns false. It then resolves the full path using Node's resolution algorithm and checks the extension: `.mjs`, `.cjs`, `.node` and `.wasm` are valid, a `.js` file is not automatically valid, and a file matching a known mixed syntax pattern such as `.esm.js` or `.es.js` is not valid.

Then it reads the closest package.json, and if the `type: 'module'` field is set the answer is true. Failing that, it reads the source code of the resolved path and tries to detect CommonJS syntax usage, in which case the answer is true, and then tries to detect ESM syntax usage, in which case the answer is false.

The final step is the subtle one. If the source contains both, the answer depends on which check ran last, which is a heuristic dressed as a decision.

The README is honest about the limits. There might still be edge cases the algorithm cannot cover, and it is designed with best-efforts. The method also allows using dynamic import of CommonJS libraries, considering that Node.js has interoperability with CommonJS.

The three lower-level detectors are simpler. `hasESMSyntax` detects whether code uses ESM syntax, meaning static import, ESM export and import.meta usage. `hasCJSSyntax` detects CommonJS syntax, meaning exports, module.exports, require and global usage. And `detectSyntax` tests code against both, with an `isMixed` flag indicating that both were detected, which the README calls a common case with legacy packages exporting semi-compatible ESM syntax meant to be used by bundlers.

That mixed case is the reason detectSyntax exists as a separate function rather than being folded into the other two. A package that is neither cleanly one nor the other needs to be recognised as such, and a tool that has to decide how to handle it needs to know it is mixed rather than guessing.

Import scanning and the CommonJS context

The import and export analysis utilities are the other half of the package, and they are deliberately fast rather than fully general.

The description says it plainly: tools to quickly analyse ESM syntax and extract static import and export, with a super fast regex-based implementation, handling most edge cases, finding all static ESM imports, finding all dynamic ESM imports, parsing the static import statement, and finding all named, declared and default exports.

The regex approach is the interesting choice. A real parser would be more correct, and acorn is already a dependency of the package, so the correctness was available. The trade is speed, and for a tool that has to scan every file in a large project that is the right way round.

`findStaticImports` returns matches with a good deal of structure. Each match carries the type, the imports clause, the specifier, the original code, and the start and end offsets, so a caller can both inspect what was imported and locate it in the source.

`parseStaticImport` then takes one of those matches and parses it further, breaking the import clause into its pieces so that you can tell a default import from named imports from a namespace import, and get the local names right including renames.

The CommonJS context utility is the other feature in this group and it exists to solve a concrete problem. `createCommonJS` creates a compatible CommonJS context that is missing in ECMAScript modules:

js
import { createCommonJS } from "mlly";

const { __dirname, __filename, require } = createCommonJS(import.meta.url);

So an ESM file that needs `__dirname` or `__filename` or a `require` function gets them from here, and the values are derived from the importing module's URL.

The implementation detail worth knowing is that `require` and `require.resolve` are lazy functions. The `createRequire` call happens on first usage rather than at creation time, which is the right behaviour: creating the context should be cheap, and a module that never calls require should not pay for building one.

Taken together, the resolution utilities, the syntax detectors, the import scanners and the CommonJS context are the four things a tool needs to work with a mixed Node.js module landscape, and having them in one place with one test suite is the value.

How the package is built and released

The package also has a version and a distribution story that tells you how it is consumed.

The manifest marks the package as `type: module`, so the source is ESM, and the exports map gives three entry points: types at `dist/index.d.ts`, import at `dist/index.mjs` and require at `dist/index.cjs`. The main and module fields point at the same two files for older tooling, and only `dist` is published.

The build is unbuild, which is the same tool the wider unjs ecosystem uses, and the scripts are worth reading because they show the whole release path in one block. `build` runs unbuild, `dev` runs vitest, `lint` runs eslint over `src` and `test` and then prettier in check mode, and `test` runs the lint, the type check and vitest in sequence. The type check is `tsc --noEmit`, so type errors fail the test run rather than sitting in a separate job.

The release script is the one that shows the project's habits. It runs the tests, then the build, then changelogen in release mode, then npm publish, then pushes with follow-tags. Generating the changelog from commit messages rather than writing it by hand, and letting the tag follow the publish, is the standard arrangement for a package with this many small releases, and it is why the CHANGELOG.md at the repository root is consistent.

The development dependencies are the tooling that makes those scripts work. Vitest with coverage through the v8 provider, unbuild for the bundle, changelogen for the changelog, eslint with the unjs config, prettier, and TypeScript. Two of them are worth noticing for what they enable. `import-meta-resolve` is the reference implementation of the resolver the package wraps, so it is present as a dev dependency and the runtime behaviour comes from it. And `jiti` is the runtime that lets a Vitest suite load TypeScript sources directly without a separate compile step, which is what makes the test loop fast.

The repository layout follows the same small shape as the package. There is a `src/` directory for the implementation, a `test/` directory, a `build.config.ts` for the bundler configuration, `tsconfig.json`, an eslint flat config at `eslint.config.mjs`, a prettier config, a renovate configuration for dependency updates, and a pnpm lock file.

That is the whole project: a source directory, a test directory, and a manifest that pins the toolchain. The size of the package is a deliberate consequence of the design, and the presence of acorn, pathe, pkg-types and ufo as the only runtime dependencies means the library is not carrying a parser of its own or a path library of its own.

Editorial conclusion

Adopt mlly if you are writing tooling that has to resolve or inspect modules the way Node does, because the resolver and the syntax detection cover the awkward cases that a hand-written path join does not. Do not build on isValidNodeImport as if it were authoritative, because the README itself describes the algorithm as best-effort with edge cases, and check first whether the answer you need is already in Node, since several of these utilities exist because the platform still lacks them rather than because they are better.

Frequently asked questions

What is mlly?

It is a TypeScript utility library described as the missing ECMAScript module utils for Node.js. It provides resolution that follows the ECMAScript resolver algorithm, syntax detection for ESM and CommonJS, static and dynamic import scanning, a CommonJS context for ESM code, and a ponyfill for import.meta.resolve. It is MIT licensed and part of the unjs organisation.

How do I install mlly?

With `yarn add mlly` or `npm install mlly`, with Node.js 14 or newer recommended. The package ships both ESM and CommonJS builds with type declarations, so it can be imported with a named import or required, and the manifest marks it with sideEffects false so bundlers can drop it when unused.

What options does mlly's resolve function take?

The url to resolve from, defaulting to the current working directory; the conditions array used by the resolution algorithm, defaulting to ['node', 'import']; and the extensions array checked if the import failed, defaulting to ['.mjs', '.cjs', '.js', '.json']. resolvePath and resolvePathSync are the same but return a path rather than a URL.

How does mlly's isValidNodeImport work?

It checks the import protocol first, then resolves the full path with Node's resolution algorithm, then checks the extension, then reads the closest package.json for a type of module field, and failing that reads the source to detect CommonJS or ESM syntax. The README describes it as best-effort and warns that edge cases remain, so it should be treated as a heuristic rather than a guarantee.

What can I do with mlly's import analysis functions?

findStaticImports finds all static ESM imports with their type, imports clause, specifier, original code and source offsets, and parseStaticImport parses one of those matches further. The implementation is described as super fast and regex based, handling most edge cases, and detectSyntax tests code against both ESM and CommonJS with an isMixed flag for legacy packages that are both.

Is mlly still being maintained?

The repository is not archived and the last push was on 2026-10-20. Releases v1.8.2, v1.8.1 and v1.8.0 came in March 2026, March 2026 and August 2025, the package is built with unbuild and tested with Vitest, and the manifest pins pnpm 10.33.4 as the package manager with esbuild as the only dependency allowed to run build scripts.

Official sources

  1. Issues
  2. License: MIT
  3. README
  4. Releases
  5. unjs/mlly on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/unjs-mlly.svg)](https://hysenlabs.com/projects/unjs-mlly)

Was this useful?