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

Support package.json exports outside of Node #50794

Closed
andrewbranch opened this issue Sep 15, 2022 · 17 comments · Fixed by #51669
Closed

Support package.json exports outside of Node #50794

andrewbranch opened this issue Sep 15, 2022 · 17 comments · Fixed by #51669
Assignees
Labels
Experience Enhancement Noncontroversial enhancements Fix Available A PR has been opened for this issue Suggestion An idea for TypeScript

Comments

@andrewbranch
Copy link
Member

andrewbranch commented Sep 15, 2022

package.json exports are only supported in --moduleResolution node16 and --moduleResolution nodenext. This is expected behavior, but we are working on expanding it. A few points of common confusion:

  • If you’re using Node 12 or 14, you should use --moduleResolution node16. The features added to 16 have fully or mostly been backported to 12 and 14.
  • --moduleResolution node is for Node 11 and older. Its name is currently bad. We plan to rename it accordingly.
  • If you are using a bundler or a non-Node runtime, we do not currently have a mode for you. node16 and nodenext are only for Node.*

This has been thoroughly discussed elsewhere, so I won’t go into much more detail—I’m creating this just to have an issue to track and point to, since #33079 is closed, but people continue to show up thinking it’s incomplete due to lack of support outside of Node. If you want to participate in discussion about current or future support for package.json exports or module resolution outside of Node more generally, it is critical to read #50152 first. Please do not comment here without reading that context.

* It’s worth noting that the (intentional) lack of support for package.json exports in the node (to be renamed node11 soon) mode can be, and has been, worked around for bundler users by many libraries in one of two ways:

(EDIT: This is now documented in detail at https://github.com/andrewbranch/example-subpath-exports-ts-compat)

  1. Arranging index files and subdirectory package.json files of the library such that Node 11 (non-exports-respecting) and Node 12+ (exports-respecting) reach the same result
  2. Setting a typesVersions that mirrors the structure of exports such that TypeScript’s --moduleResolution node will reach the same result as TypeScript’s --moduleResolution nodenext. Note that in this scenario, other resolvers that don’t respect exports, like Node 11, Parcel, and Browserify, will be broken. The syntax for typesVersions is very similar, but not identical, to that of exports. If you are a library author who intends to support Webpack, esbuild, Vite, Node 12+, etc., but not Parcel, Browserify, or Node 11, and you need help figuring out how to set up typesVersions as an interim workaround, feel free to ask here and I’ll assist.
@Mitsunee
Copy link

Mitsunee commented Sep 16, 2022

  • If you’re using Node 12 or 14, you should use --moduleResolution node16. The features added to 16 have fully or mostly been backported to 12 and 14.
  • --moduleResolution node is for Node 11 and older. Its name is currently bad. We plan to rename it accordingly.

Incorrect. As I have explained in #33079 (multiple times, with at least 2 attempts of linking a repo, both of which were ignored) package.json exports fields are fully supported by moduleResolution node, they are just broken out of the box.

To reiterate the issue: As a library author you want your library to work for as many users as possible. This means it NEEDS to work with ANY resolution mode. Currently resolution mode "node" (the default!) requires the files linked in the exports[].types key to be in the root of the package

"exports": {
  "./": {
    "types": "./index.d.ts", // works fine, even if it re-exports types!
    "import": "./dist/index.js",
    "require": "./dist/index.cjs"
  },
  "./some-module": {
    "types": "./dist/types/some-module.d.ts", // does not work but should
    "import": "./dist/some-module.js",
    "require": "./dist/some-module.cjs"
  }
}

The resolution mode "node" should be fixed and not renamed. The other two options ("node16" and "nodenext") have actual breaking changes and are not even known to exist to 90% of users.

If you are renaming anything, then you should rename "node16" as that is a terrible name that will eventually be outdated and changing it then will cause even more confusion than renaming it to "node-lts" (or "node-latest"? See it's too confusing already, I don't know which it is supposed to be currently) now.

Add a "node-legacy" option for people not concerned with security who are sticking with node11 or earlier - the default option should work as a default for 90% of users.

EDIT: Here is my current workaround to the broken type import resolution:

https://github.com/Mitsunee/foxkit/blob/bc7bb67355771a615d2dd57c2c700cb85030467c/packages/node-util/fs.d.ts#L1

I literally have to make a new file re-exporting the type declarations generated BY TYPESCRIPT.

@andrewbranch
Copy link
Member Author

Incorrect.... package.json exports fields are fully supported by moduleResolution node, they are just broken out of the box

It’s a weird situation to have to assert my credentials on an issue that I authored in a project that it is my full-time job to work on, but here we are? I’ve worked on the TypeScript for the last 3.5 years and have spent nearly every working hour of the last 3 months or so on module resolution. I assure you, there are at most 2–3 people who know as much or more than me about TypeScript’s module resolution as it stands today, and they all have the “Member” badge on this GitHub repo. I’m not willing to debate the validity of the broad claims I’ve made in this issue. Secondly, this issue is about bundlers and non-Node runtimes, so while I appreciate that you were directly responding to my comments about Node, I don’t want to further discuss existing functionality (or naming conventions) intended for Node here. As I said in #33079, if you think you have a bug, please open a new issue. Thank you for understanding.

@Mitsunee
Copy link

I’m not willing to debate the validity of the broad claims I’ve made in this issue. Secondly, this issue is about bundlers and non-Node runtimes, so while I appreciate that you were directly responding to my comments about Node, I don’t want to further discuss existing functionality (or naming conventions) intended for Node here.

Bundlers are exactly the place where the bug I am trying to bring to attention is of concern. Projects that don't use a bundler can simply refactor to node16 resolution, while bundlers often require the "node" module resolution mode and will throw errors when given import paths with file extension, which is what both "node16" and "nodenext" require.

@andrewbranch
Copy link
Member Author

Right, this issue will be resolved by a new module resolution mode for bundlers, which will include package.json exports support but will not require file extensions. Reminder:

If you want to participate in discussion about current or future support for package.json exports or module resolution outside of Node more generally, it is critical to read #50152 first. Please do not comment here without reading that context.

@andrewbranch
Copy link
Member Author

Brief off-topic:

I’m not willing to debate the validity of the broad claims I’ve made in this issue.

FWIW, I really dislike how this came off. There has been so much confusion around topics of module resolution, so I wanted the information in this issue to be clear, concise, and authoritative, and to be immediately challenged on the fundamental facts underlying it would seem to undermine that goal. I wanted anyone else trying to follow along to rest assured that what I said in the issue body is correct and settled. Working on TypeScript certainly doesn’t make me an authority on everything about it and in most contexts I’m happy to talk through disagreement. The reason I don’t want to debate it here is just that this issue is intended to clarify, and I worked hard to ensure that the narrow set of information I presented is accurate. Attempting to prove my statements would just muddy the waters and be a nuisance for future readers—anyone really interested can validate them by reading moduleNameResolver.ts, but hopefully most folks can just trust me on this. At any rate, I apologize for the arrogant tone.

@fictitious
Copy link

fictitious commented Sep 19, 2022

If you are a library author who intends to support Webpack, esbuild, Vite, Node 12+, etc., but not Parcel, Browserify, or Node 11, and you need help figuring out ...

I'd like to get some help figuring out how to make a library which will work for as many users as possible, that is:

  • users who have "type": "commonjs" in package.json, and use "moduleResolution": "node" - that's what everyone was doing a while ago

  • users who have "type": "module" in package.json, and use "moduleResolution": "nodenext" - that's what everyone is supposed to do in the future

  • users who have "type": "module" in package.json, but use "moduleResolution": "node" - such packages do exist

  • users who have "type": "commonjs" in package.json, but use "moduleResolution": "nodenext" - such packages also do exist

As far as runtime is concerned, there are only two variants of consumers, and the solution is not complicated: just transpile or bundle your code to produce commonjs variant, and use "main" for old node and "exports" for the modern node, which is also supported by modern bundlers:

{
    "exports": {
        ".": {
            "import": "./dist/index.mjs",
            "require": "./dist/index.cjs"
        }
    },
    "main": "dist/index.cjs",
}

What I'd like to know is how to produce type declarations that will be usable by all 4 variants of the consumers?

@andrewbranch
Copy link
Member Author

andrewbranch commented Sep 19, 2022

and add nested package.json there with just one line

Why is this nested package.json necessary in your example?

EDIT: All the following information is now presented in a much more thorough and organized way at https://github.com/andrewbranch/example-subpath-exports-ts-compat. You could stop reading this comment and look through there instead.

Definitely the easiest way to have subpath exports that “work” even in module resolvers that don’t support them is to do away with having a dist folder in your npm output and put your type definitions in the same directory as your JS files. So you might have something like

"type": "commonjs",
"exports": {
  "./subpath": {
    "import": "./subpath/index.mjs",
    "default": "./subpath/index.js"
  },
  // ...
}
subpath/
├─ index.mjs
├─ index.js
├─ index.d.mts
├─ index.d.ts

(Notice that I don’t even have to specify exports["./subpath"]["types"] because each JS file has a definition file sibling that matches its extension.) When any module resolver that supports exports tries to resolve an import path of "your-library/subpath", it will follow the package.json. When any module resolver that doesn’t support exports resolves the same path, it will just use the directory structure and find subpath/index.js / subpath/index.d.ts under the normal index-file lookup rules.

If you must have a dist folder, and/or must put your type definitions in a separate directory, this gets more complicated. The first way is to use directories with package.json files to redirect non-exports-supporting resolvers:

// @Filename: package.json
"type": "commonjs",
"exports": {
  "./subpath": {
    "types": "./types/subpath/index.d.ts",
    "import": "./esm/subpath/index.mjs",
    "default": "./cjs/subpath/index.js"
  },
  // ...
}
// @Filename: subpath/package.json
{
  "main": "../cjs/subpath/index.js",
  "types": "../types/subpath/index.d.ts"
}

(🚨 Warning: I’m showing a types/import/default trio here because it’s very common, but this can be a hazard in itself. It’s generally a better idea to have separate types for your ESM and CJS files and let them be matched up by file extension, as in my earlier example. If you use any default exports in ESM-format files, you must have separate types for the ESM and CJS variants. See #50690.)

Again, resolvers that support exports will be immediately directed into the right output folder by the root package.json, while resolvers that don’t will hit the subpath/package.json stub and be redirected from there. (That stub is only concerned with CJS variants, because resolver support for ESM and exports is pretty much 1:1 AFAIK.)

The other way to do this, which only changes the way TypeScript resolves and not any other runtimes or resolvers, is to set up a typesVersions that mirrors the exports:

"type": "commonjs",
"exports": {
  "./subpath": {
    "types": "./types/subpath/index.d.ts",
    "import": "./esm/subpath/index.mjs",
    "default": "./cjs/subpath/index.js"
  },
},
"typesVersions": {
  "*": { // all versions of TypeScript should respect this map
    "subpath": ["./types/subpath/index.d.ts"] // syntax is an array
  }
}

While this approach does not help support Node 11, Parcel, Browserify, etc., one advantage is it supports wildcards. All together:

"type": "commonjs",
"exports": {
  ".": {
    "types": "./types/index.d.ts",
    "import": "./esm/index.mjs",
    "default": "./cjs/index.js"
  },
  "./subpath": {
    "types": "./types/subpath/index.d.ts",
    "import": "./esm/subpath/index.mjs",
    "default": "./cjs/subpath/index.js"
  },
  "./*": {
    "types": "./types/*.d.ts",
    "import": "./esm/*.mjs",
    "default": "./cjs/*.js"
  }
},
"typesVersions": {
  "*": {
    "subpath": ["./types/subpath/index.d.ts"],
    "*": ["./types/*.d.ts"]
  }
},
"types": "index",
"main": "cjs/index.js"

Notice here that the top-level "types" itself actually feeds through the pattern provided in "typesVersions", so "index" gets resolved to "./types/index.d.ts". Having a wildcard that unconditionally maps TypeScript into the types folder in a directory structure like this is good, because it prevents imports like "your-library/types/subpath" from resolving, which would be a problem at runtime since there’s no JS in the types folder.

This complexity is why I’m not a fan of declarationDir and think the happiest path is if you can avoid publishing a dist folder in your npm structure altogether, but hopefully this gives you some strategies you can employ in different situations.

In every example I gave here, the library being imported is "type": "commonjs". All this controls is the implied format of .js and .d.ts files—the package can still be just as much ESM by shipping .mjs and .d.mts files, whose extension makes them explicitly ESM. I think this approach works better than flipping it to "type": "module", letting .js files be ESM, and needing .cjs/.d.cts extensions to force a file to be interpreted as CommonJS, because those files are not eligible for extensionless or index-file lookups, which was a technique I relied on in my first example. If you don’t rely on that technique, you can probably use "type": "module" if you must and arrange everything else to work, but if your goal is maximum compatibility, a better way to set yourself up for success is to use a format where every resolver, old and new, agrees that .js and .d.ts files are CJS, then put ESM on top of that where supported.

I gave these examples from memory, so while I’m quite sure the overall ideas are sound, beware that there may still be typos or small errors. I’ll try to port this to a working repo today or tomorrow and fix any errors that turn up in the process.

@frehner
Copy link

frehner commented Sep 19, 2022

Definitely the easiest way to have subpath exports that “work” even in module resolvers that don’t support them is to do away with having a dist folder in your npm output and put your type definitions in the same directory as your JS files. So you might have something like

Forgive my ignorance: does this example imply that the project source code has both index.mts and index.ts files? If so, what happens if the source code is only written in one format (let's say esm in a .ts file), would the compiled output for esm and cjs and their corresponding declaration files conflict (in other words, would both compilations output a index.d.ts file?)?

Hopefully this question makes sense. Sorry if it doesn't.

@andrewbranch
Copy link
Member Author

In talking about the published package contents, I wasn’t necessarily making any assumptions about how it was generated. tsc isn’t designed to help you generate dual ESM and CJS packages from a single source; while it might be possible to do that in some circumstances, it would be really easy to mess it up. tsc will always output a declaration file with an extension that matches the input file, so if a .ts is interpreted as ESM during the compilation, but later you flip the package.json "type" to "commonjs", the .d.ts file that got emitted might be problematic. Given that generating dual-mode packages is not a goal of tsc, I would recommend folks use an external tool that is designed to do that, but I don’t have personal experience with any so can’t make a recommendation. It’s something I hope to investigate later.

@fictitious
Copy link

First, thanks for the detailed explanations!
 

Why is this nested package.json necessary in your example?

Because the package itself can then be "type": "module" - javascript tooling is already mostly ESM, and having config .js files which are actually ESM is becoming the norm.
 

If you use any default exports in ESM-format files, you must have separate types for the ESM and CJS variants. See #50690.)

Thanks for the heads up, that's one more argument against default exports I guess.
 

In every example I gave here, the library being imported is "type": "commonjs". All this controls is the implied format of .js and .d.ts files—the package can still be just as much ESM by shipping .mjs and .d.mts files, whose extension makes them explicitly ESM.

If all d.ts are commonjs in a published library, and if the library consumer uses "moduleResolution": "nodenext", then the library can only use dependencies which also have only commonjs type declarations. Attempt to import a ESM module gives an error

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("dual-lib")' call instead.

On the flip side, due to the same error, as soon as you start shipping ESM types, the library becomes unusable by consumers who are commonjs and use "moduleResolution": "nodenext".

I don't know any way around this, it looks like keeping the types commonjs is the only option for the time being, can't wait until TypeScript ships the promised new module resolution mode for bundlers.
 

Given that generating dual-mode packages is not a goal of tsc, I would recommend folks use an external tool that is designed to do that, but I don’t have personal experience with any

From my limited experience with rollup, it looks like rollup-plugin-typescript could do the job if you run rollup twice with different configs. There's also transform which could be plugged into rollup-plugin-typescript to add .js extensions to relative imports in ESM output, but I haven't tried it myself yet.

@andrewbranch
Copy link
Member Author

If all d.ts are commonjs in a published library, and if the library consumer uses "moduleResolution": "nodenext", then the library can only use dependencies which also have only commonjs type declarations. Attempt to import a ESM module gives an error

Yeah, good point. But this is an issue whether the library is using TypeScript / shipping types or not. I would rephrase your sentence to:

If all .js are CommonJS-format in a published library, and if the library consumer uses any ESM-format files in Node, then the library can only (synchronously) use dependencies which are also CommonJS-format.

This is the core problem library authors are facing with Node. However they choose to solve or work around it, the TypeScript types just follow the implementation.

@andrewbranch
Copy link
Member Author

I’ll try to port this to a working repo today or tomorrow

As promised: https://github.com/andrewbranch/example-subpath-exports-ts-compat

and fix any errors that turn up in the process

I didn’t notice any issues with the examples I posted, but the ones in the repo are programmatically validated, more complete, and discussed in more detail.

unicornware added a commit to flex-development/dist-tag that referenced this issue Sep 28, 2022
unicornware added a commit to flex-development/dist-tag that referenced this issue Sep 28, 2022
unicornware added a commit to flex-development/dist-tag that referenced this issue Sep 28, 2022
unicornware added a commit to flex-development/aggregate-error-ponyfill that referenced this issue Sep 30, 2022
@nivida
Copy link

nivida commented Oct 8, 2022

Is there any eta for this? would be great if the following case with module/nodenext:

...
"./some-module": {
    "types": "./dist/types/some-module.d.ts", // does not work but should
    "import": "./dist/some-module.js",
    "require": "./dist/some-module.cjs"
  }
...

... would work..

Because if you create a lib is it great to separate externally exposed paths from the actual internal ones.

@andrewbranch
Copy link
Member Author

@nivida that already works in --module nodenext; you can see an example here

@cefn
Copy link

cefn commented Dec 5, 2022

As someone who's in the apparently unusual camp of having a project with type:module and moduleResolution:node I wanted to point out that at least for my case, 'upgrading to nodenext' was a breaking change that I had to reverse, since nodenext caused compilation to resolve to the types entry of exports over and above the source files in my monorepo.

So I'd be concerned if broadening the application of node16 and nodenext (and maybe retiring/deprecating node) meant I ended up with no modes that allowed me to guide tsc to resolve packages to actual Typescript source locally.

Let me know if this is the wrong issue to document this problem and there's a better place for it.

With moduleResolution:node

This was my proven, reference config. I double-checked via tsc --traceResolution that packages internal to the monorepo (like @starter/sum referenced by @starter/multiply) were correctly fulfilled directly by their source files, and without pointing to build artefacts and .d.ts by mistake (which would make local dev error prone). Here's the proof...

[packages/multiply]$ npx tsc --traceResolution | grep @starter/sum | more
======== Resolving module '@starter/sum' from '/home/cefn/Documents/github/starter/packages/multiply/src/index.ts'. ========
======== Module name '@starter/sum' was successfully resolved to '/home/cefn/Documents/github/starter/packages/sum/src/index.ts'. ========

With moduleResolution:node16 or moduleResolution:nodenext

This effectively introduces a regression to my tooling, by looking at exports, then ignoring the order of default vs types, and settling on the types .d.ts build artefact in second position, rather than the .ts sibling of the default .js target in first position. Here's the proof...

[packages/multiply]$ npx tsc --traceResolution | grep @starter/sum | more
======== Resolving module '@starter/sum' from '/home/cefn/Documents/github/starter/packages/multiply/src/index.ts'. ========
======== Module name '@starter/sum' was successfully resolved to '/home/cefn/Documents/github/starter/packages/sum/dist/esm/index.d.ts' with Package ID '@starter/sum/dist/esm/ind
ex.d.ts@1.0.0'. ========

You can see the reference repo at https://github.com/cefn/starter/tree/8bd9ed9311840e8ca782e13ed6a99d4872f77a02 (a reference commit of the export-typescript-source branch of the repo which demonstrates the issue). The repo is only there to explore this kind of thing, so I'm happy to try workarounds.

@audunolsen
Copy link

nodenext doesn't seem to honour exports field when utilising subpath-patterns. Are there workarounds for this? Or plans to implement support for it? I generally want to do something like this in my package instead of introducing package.json bloat and possible maintenance issues.

"files": [
  "/dist"
],
"exports": {
  "./*": "./dist/*.js",
  "./dist/internals/*": null
},

@andrewbranch
Copy link
Member Author

It does support subpath patterns. I’d recommend using tsc --traceResolution to figure out what’s going wrong in your case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Experience Enhancement Noncontroversial enhancements Fix Available A PR has been opened for this issue Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants