TypeScript: NodeNext resolution failed to resolve dual-package correctly

Bug Report

NodeNext resolution failed to resolve dual-package correctly

🔎 Search Terms

NodeNext

🕗 Version & Regression Information

  • Never worked

💻 Code

https://github.com/Jack-Works/ts-nodenext-wrong-resolution-reproduction

where node_modules/testpkg/package.json is

{
    "name": "testpkg",
    "exports": {
      ".": {
        "types": "./dist/type.d.ts",
        "require": "./dist/common.cjs",
        "import": "./dist/module.mjs"
      }
    }
  }

TypeScript should resolve type.d.ts in dual mode instead of CommonJS synthetic export.

🙁 Actual behavior

src/index.ts:2:1 - error TS2349: This expression is not callable.
  Type 'typeof import("testpkg/dist/type")' has no call signatures.

🙂 Expected behavior

No error

About this issue

  • Original URL
  • State: open
  • Created 2 years ago
  • Reactions: 10
  • Comments: 23 (12 by maintainers)

Most upvoted comments

To add to the dialog:

It took ages to find this solution in order to get dual package exports working properly for TypeScript, but this solution by @Jack-Works works for us 🎉 (with some “gotchas” mentioned below).

Our package.json for our ESM “library” looks like:

{
  "name": "library",
  "type": "module",
  "exports": {
    "./foo": {
      "import": {
        "types": "./dist/foo.d.ts",
        "default": "./dist/foo.js"
      },
      "require": {
        "types": "./dist/foo.d.cts",
        "default": "./dist/foo.umd.cjs"
      }
    },
    "./bar": {
      "import": {
        "types": "./dist/bar.d.ts",
        "default": "./dist/bar.js"
      },
      "require": {
        "types": "./dist/bar.d.cts",
        "default": "./dist/bar.umd.cjs"
      }
    }
  }
}

Having the separate *.d.cts file was the key to getting the library to work for CJS consumers, as mentioned in this thread.

Then, we are able to have our CJS “consumer” use this ESM “library” with the following tsconfig.json:

{
  "compilerOptions": {
    "target": "ES6",
    "module": "NodeNext"
  }
}

We could have also used module=CommonJS in combination with moduleResolution=NodeNext. Using moduleResolution=NodeNext (which is the default when module=NodeNext) is necessary because our “library” package only defines an exports field and not a main. This is because we are using subpath exports to export multiple entry points, and don’t need to support older versions of Node.

Here is the kicker: Simply copying the *.d.ts file and renaming it to *.d.cts as mentioned by @Jack-Works was not sufficient for our case. This is because our “library” is an ESM package with relative path references, e.g.:

// @file foo.ts
export { util } from './util.js';

Note the .js extension - this is because ESM requires file extensions for relative paths, and TypeScript does not rewrite paths from .ts to .(m/c)js.

When foo.ts is compiled with TypeScript (and emits declarations), the foo.d.ts file (and the copied foo.d.cts file) will look identical. Then, when our CJS “package” tries to compile our “library” using TypeScript, it sees the .js extension and is not clever enough to treat it as CommonJS. It results in the following error:

“The current file is a CommonJS module whose imports will produce ‘require’ calls; however, the referenced file is an ECMAScript module and cannot be imported with ‘require’. Consider writing a dynamic 'import(”./util.js")’ call instead."

The fix: rewrite all .js extensions in import and export references in our *.d.cts files to instead be .cjs. That means we have a script that runs after build that looks like this:

import { readFileSync, writeFileSync } from 'node:fs';

import { globby } from 'globby';

const paths = await globby(['dist/**/*.ts']);

for (const esmPath of paths) {
  const esmContent = readFileSync(esmPath, { encoding: 'utf8' });

  // Fix file extensions
  const cjsPath = esmPath.replace('.d.ts', '.d.cts');

  // Fix import/export references
  const cjsContent = esmContent.replace(/(.+ from )'(.+).js'/g, "$1'$2.cjs'");

  writeFileSync(cjsPath, cjsContent);
}

This feels very hacky and we would absolutely like to ditch this completely - but it seems to be the only way to achieve the following:

  • Writing a pure ESM library using TypeScript that aligns with the NodeJS standards
  • Using conditional exports to support both CJS and ESM consumers
  • Using subpath exports that work for CJS and ESM consumers

We would ❤️ to see more native support that doesn’t require so much juggling.

I am one of the maintainers of a “dual-package” and after reading this thread I have some questions related to the thread comments:

  1. Is that correct that we should have one type declaration per file?
  2. Should the type declarations uses the matching import/export semantics for the module system they are matching? (export for .d.mjs and module.exports for .d.cjs)
  3. Does TypeScript will assume that a file ending with .d.ts is going to be an ESM if the type property is set to module in the package manifest?

More detailed below:

The package in question is declared as ES Module (the type property is set to module in the package manifest file). The package doesn’t have main entry points per se and is structured as follows:

├── dist/
    ├── foo/
        ├── someFile.cjs  # Common JS
        ├── someFile.d.ts # Type declaration  
        ├── someFile.js   # ESM

In this case, both the CJS and the ESM implementation expose the exact same API (actually the CJS implementation is transpiled from the ESM one via Rollup inside a compiler).

That’s how the exports look like for each implementation:

# someFile.js (ESM)
export default class C {}
# someFile.cjs (CJS)
class C {}
module.exports = C;

That’s how the type declaration looks like:

export default class C {}

And that’s how we configured the exports map in the package manifest:

"./*": {
    "types": "./dist/*.d.ts",
    "import": "./dist/*.js",
    "require": "./dist/*.cjs"
}

As soon as TS 4.8 has been released I tried to switch moduleResolution to NodeNext but it seems like I am hitting issues due to the fact that I am only using one declaration file if my understanding is correct. So basically, the format of my export map is wrong and I should opt for something close to what @DanielRosenwasser suggested.

I am also asking as the type declaration is being generated from our compiler and I will have to think about how we can generate one for CJS if we have to.

For instance, while trying to use the package in a project that uses CommonJS I am getting the following error from tsserver:

The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require' (ts1479)

Thanks in advance for your input.

image

People are writing the wrong package.json again and again in the wild. Example: https://github.com/react-hook-form/resolvers/issues/460 I really think this is a problem the TS team should take care of if TS is really serious about fixing the ecosystem.

Thx for your answers @weswigham and @Jack-Works. I think my confusion come from the latest code snippet from this page of the handbook: https://www.typescriptlang.org/docs/handbook/esm-node.html#packagejson-exports-imports-and-self-referencing

By default, TypeScript overlays the same rules with import conditions - if you write an import from an ES module, it will look up the import field, and from a CommonJS module, it will look at the require field. If it finds them, it will look for a colocated declaration file. If you need to point to a different location for your type declarations, you can add a “types” import condition

// package.json
{
    "name": "my-package",
    "type": "module",
    "exports": {
        ".": {
            // Entry-point for TypeScript resolution - must occur first!
            "types": "./types/index.d.ts",
            // Entry-point for `import "my-package"` in ESM
            "import": "./esm/index.js",
            // Entry-point for `require("my-package") in CJS
            "require": "./commonjs/index.cjs",
        },
    },
    // CJS fall-back for older versions of Node.js
    "main": "./commonjs/index.cjs",
    // Fall-back for older versions of TypeScript
    "types": "./types/index.d.ts"
}

Which seems to contradict what is being discussed here. Should the documentation be updated?

That makes sense, thanks a lot for all the very useful information, I can see what I should be doing now!

You can’t generally assume that an import in an ESM file will resolve the same when you convert that file to a CJS context. This is by design, of course; you’re taking advantage of conditional exports yourself. If you leave the imports untouched, TypeScript will resolve them “correctly,” but will it resolve to the same thing as it did in an ESM context? If not, will the import syntax used be appropriate for the different module it resolves to? Will the resulting file type check and do what you expect? You have to answer these questions yourself for every individual import.

You must have a separate TS file (with matching format) for each [entrypoint] … if you want accurate checking.

All module resolution is in moduleNameResolver.ts.