Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
markw65 opened this issue Mar 1, 2023 · 9 comments
Labels
Duplicate An existing issue was already created

Comments

@markw65
Copy link

markw65 commented Mar 1, 2023

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.

@RyanCavanaugh RyanCavanaugh added the Needs Investigation This issue needs a team member to investigate its status. label Mar 2, 2023
@weswigham
Copy link
Member

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.

@RyanCavanaugh
Copy link
Member

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

@markw65
Copy link
Author

markw65 commented Mar 2, 2023

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"],
}

@RyanCavanaugh
Copy link
Member

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).

@RyanCavanaugh
Copy link
Member

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

@RyanCavanaugh RyanCavanaugh removed the Needs Investigation This issue needs a team member to investigate its status. label Mar 3, 2023
@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Mar 3, 2023
@RyanCavanaugh
Copy link
Member

I'm going to fold this into #52593

@typescript-bot
Copy link
Collaborator

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

@andrewbranch
Copy link
Member

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!

@markw65
Copy link
Author

markw65 commented Mar 7, 2023

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

5 participants