microsoft/TypeScript

moduleResolution node16 conflicts with type "module" packages that export commonjs

markw65 opened this issue ยท 9 comments

Bug Report

I have a package with "type": "module" which uses the "exports" field to indicate how to use it via require:

  "type": "module",
  "exports": {
    ".": {
      "types": "./build/index.d.ts",
      "require": "./build/index.cjs",
      "import": "./build/index.cjs"
    }
  },

and I have a project targeting commonjs that wants to use it. In order to do so, I have to set "moduleResolution":"node16" (or nodenext) in my project (the real package has multiple exports, so I can't just fall back to "main" and "types").

But now I get an error:

src/index.ts:1:21 - error TS1479: 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("package")' call instead.
  To convert this file to an ECMAScript module, change its file extension to '.mts' or create a local package.json file with `{ "type": "module" }`.

even though the package explicitly exports a require friendly version.

In addition, if index.d.ts in the package imports from another file eg:

import { foo } from "./util";

I get another error:

../package/build/index.d.ts:1:21 - error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './util.js'?

1 import { foo } from "./util";

I sort of get why this happens; but the .d.ts was generated by tsc in the first place, and is solely for tsc's consumption. And since I'm generating commonjs from a commonjs export, it seems like this really shouldn't apply.

If I change the type of the package to "commonjs" all the issues go away. But I don't necessarily have control over the package, and even if I do, it seems reasonable that I should be able to develop it as a module (ie have .js files treated as esm rather than commonjs) while providing suitable exports for both commonjs and esm. And I should add that this actually works as far as node is concerned.

๐Ÿ”Ž Search Terms

moduleresolution node16 commonjs

๐Ÿ•— Version & Regression Information

4.7 (when ts first added support for "exports" in package files).

  • This is the behavior in every version I tried, from 4.7 through @next and I reviewed the FAQ for entries about moduleResolution

โฏ Playground Link

I don't think its possible to setup the playground or workbench to demonstrate this, but here's a tiny project:

git clone https://github.com/markw65/moduleresolution-bug
cd moduleresolution-bug/package
npm install
npx tsc
cd ../project
npm install
npx tsc

results in

../package/build/index.d.ts:1:21 - error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './util.js'?

1 import { foo } from "./util";
                      ~~~~~~~~

src/index.ts:1:21 - error TS1479: 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("package")' call instead.
  To convert this file to an ECMAScript module, change its file extension to '.mts' or create a local package.json file with `{ "type": "module" }`.

1 import { baz } from "package";

๐Ÿ’ป Code

This is less of a code issue, and more related to the whole environment

๐Ÿ™ Actual behavior

tsc reports an error saying that commonjs code can't require a esmodule

๐Ÿ™‚ Expected behavior

tsc should recognize that the "exports" field explicitly exports a commonjs entry point, and use that.

Your entrypoints are .cjs files - correspondingly, your declaration files should be .d.cts files. Whatever transform you're doing on your js to make it into cjs, you need to do a corresponding transform on the declaration file.

Basically, the minimal edit to make this build is:

  • Rename package/build/index.d.ts to package/build/index.d.cts

Going above and beyond, you can write a nested export map, something like

  "exports": {
    ".": {
      "require": { "types": "./build/index.d.cts", "default": "./build/index.cjs"},
      "import": { "types": "./build/index.d.ts", "default": "./build/index.js"},
    }
  },

though that isn't necessary in this case.

We're working on a new round of module docs and "How to ship a dual-mode package" will definitely be a topic there

Thanks - that makes sense.

But now I'm puzzled as to why tsc is producing .cjs files and .d.ts files from a single build? rather than .cjs and .d.cts files?

Whatever transform you're doing on your js to make it into cjs, you need to do a corresponding transform on the declaration file

That's the point. I'm not doing a transform, I'm just running tsc in the package directory. And it produces .cjs files, and .d.ts files. Which seems kind of odd? Shouldn't it be consistent?

You can see this in the referenced project. Here's the tsconfig for easy access. Is there some other setting to control the declaration files?

{
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "build",
    "module": "commonjs",
    "target": "esnext",
    "lib": ["esnext"],
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "declaration": true,
  },

  "include": ["src/**/*.ts"],
}

What you're doing now is writing ES-style imports and producing a CJS output, which really should have never been supported by TS in the first place, but here we are. Writing dual-mode packages requires a bit more precision.

What you "should" be doing is writing either 100% ES or 100% CJS, and at the last step transmogrifying the artifacts (note: not a functionality TS itself provides) into their other-module-system versions. For a package without any dependencies this can be done with some clever regexes + copying; for a package with dependencies it's much more complex, or even possibly impossible (e.g. if one of your dependencies doesn't provide the correct set of equivalent exports).

#52086 has some context and discussion (linking mostly for my own findability benefit)

I'm going to fold this into #52593

This issue has been marked as a 'Duplicate' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

But now I'm puzzled as to why tsc is producing .cjs files and .d.ts files from a single build?

This would be news to me, and a definite bug IMO. Normally people only get themselves into situations like this by using external tools or hand-writing declaration files. But your example project doesnโ€™t show any .cts files. If you can give an example that shows tsc compiling a .cts file (or any input file, for that matter) into a .cjs and .d.ts file, please open a new bug specific to that behavior. Thanks!

@andrewbranch

Sorry, you're right. As I cut my project down to produce the test case, I failed to notice that I had (long ago) added a step to rename the output .js files to .cjs - but had never needed the same for the .d.ts files (since I had moduleResolution:node in the consuming project.

So yes, the package/build directory in my sample project does indeed end up with .js and .d.ts files; not .cjs and .d.ts files.