Repository navigation
Support package.json exports outside of Node #50794
Description
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 nodeis 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.
node16andnodenextare 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)
- 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
- Setting a
typesVersionsthat mirrors the structure ofexportssuch that TypeScript’s--moduleResolution nodewill reach the same result as TypeScript’s--moduleResolution nodenext. Note that in this scenario, other resolvers that don’t respectexports, like Node 11, Parcel, and Browserify, will be broken. The syntax fortypesVersionsis very similar, but not identical, to that ofexports. 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 uptypesVersionsas an interim workaround, feel free to ask here and I’ll assist.
Activity
Mitsunee commentedon Sep 16, 2022
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[].typeskey to be in the root of the packageThe 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 commentedon Sep 16, 2022
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 commentedon Sep 16, 2022
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 commentedon Sep 16, 2022
Right, this issue will be resolved by a new module resolution mode for bundlers, which will include package.json
exportssupport but will not require file extensions. Reminder:andrewbranch commentedon Sep 16, 2022
Brief off-topic:
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 commentedon Sep 19, 2022
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 agousers who have
"type": "module"in package.json, and use"moduleResolution": "nodenext"- that's what everyone is supposed to do in the futureusers who have
"type": "module"in package.json, but use"moduleResolution": "node"- such packages do existusers who have
"type": "commonjs"in package.json, but use"moduleResolution": "nodenext"- such packages also do existAs 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 commentedon Sep 19, 2022
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
distfolder in your npm output and put your type definitions in the same directory as your JS files. So you might have something like(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 supportsexportstries to resolve an import path of"your-library/subpath", it will follow the package.json. When any module resolver that doesn’t supportexportsresolves the same path, it will just use the directory structure and findsubpath/index.js/subpath/index.d.tsunder 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:
(🚨 Warning: I’m showing a
types/import/defaulttrio 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
exportswill be immediately directed into the right output folder by the root package.json, while resolvers that don’t will hit thesubpath/package.jsonstub and be redirected from there. (That stub is only concerned with CJS variants, because resolver support for ESM andexportsis 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
typesVersionsthat mirrors theexports:While this approach does not help support Node 11, Parcel, Browserify, etc., one advantage is it supports wildcards. All together:
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 thetypesfolder 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 thetypesfolder.This complexity is why I’m not a fan of
declarationDirand think the happiest path is if you can avoid publishing adistfolder 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.jsand.d.tsfiles—the package can still be just as much ESM by shipping.mjsand.d.mtsfiles, whose extension makes them explicitly ESM. I think this approach works better than flipping it to"type": "module", letting.jsfiles be ESM, and needing.cjs/.d.ctsextensions 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.jsand.d.tsfiles 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 commentedon Sep 19, 2022
Forgive my ignorance: does this example imply that the project source code has both
index.mtsandindex.tsfiles? If so, what happens if the source code is only written in one format (let's sayesmin a.tsfile), would the compiled output foresmandcjsand their corresponding declaration files conflict (in other words, would both compilations output aindex.d.tsfile?)?Hopefully this question makes sense. Sorry if it doesn't.
andrewbranch commentedon Sep 19, 2022
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
.tsis interpreted as ESM during the compilation, but later you flip the package.json"type"to"commonjs", the.d.tsfile 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 commentedon Sep 20, 2022
First, thanks for the detailed explanations!
Because the package itself can then be
"type": "module"- javascript tooling is already mostly ESM, and having config.jsfiles which are actually ESM is becoming the norm.Thanks for the heads up, that's one more argument against default exports I guess.
If all
d.tsarecommonjsin a published library, and if the library consumer uses"moduleResolution": "nodenext", then the library can only use dependencies which also have onlycommonjstype declarations. Attempt to import a ESM module gives an errorOn the flip side, due to the same error, as soon as you start shipping ESM types, the library becomes unusable by consumers who are
commonjsand use"moduleResolution": "nodenext".I don't know any way around this, it looks like keeping the types
commonjsis the only option for the time being, can't wait until TypeScript ships the promised new module resolution mode for bundlers.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
.jsextensions to relative imports in ESM output, but I haven't tried it myself yet.andrewbranch commentedon Sep 20, 2022
Yeah, good point. But this is an issue whether the library is using TypeScript / shipping types or not. I would rephrase your sentence to:
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 commentedon Sep 21, 2022
As promised: https://github.com/andrewbranch/example-subpath-exports-ts-compat
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.
build!: pure esm
build!: pure esm
build!: pure esm
build: set `typesVersions`
nivida commentedon Oct 8, 2022
Is there any eta for this? would be great if the following case with module/nodenext:
... would work..
Because if you create a lib is it great to separate externally exposed paths from the actual internal ones.
andrewbranch commentedon Oct 13, 2022
Samuel Furter (@nivida) that already works in
--module nodenext; you can see an example herecefn commentedon Dec 5, 2022
As someone who's in the apparently unusual camp of having a project with
type:moduleandmoduleResolution:nodeI wanted to point out that at least for my case, 'upgrading to nodenext' was a breaking change that I had to reverse, sincenodenextcaused compilation to resolve to thetypesentry ofexportsover and above the source files in my monorepo.So I'd be concerned if broadening the application of
node16andnodenext(and maybe retiring/deprecatingnode) 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 --traceResolutionthat packages internal to the monorepo (like@starter/sumreferenced by@starter/multiply) were correctly fulfilled directly by their source files, and without pointing to build artefacts and.d.tsby mistake (which would make local dev error prone). Here's the proof...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.tsbuild artefact in second position, rather than the.tssibling of thedefault.jstarget in first position. Here's the proof...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.
Support TypeScript node16/nodenext module resolution
audunolsen commentedon May 6, 2023
nodenextdoesn't seem to honourexportsfield 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.andrewbranch commentedon May 8, 2023
It does support subpath patterns. I’d recommend using
tsc --traceResolutionto figure out what’s going wrong in your case.chore(ui): fix tsconfig
chore(ui): fix tsconfig