TypeScript: "moduleResolution": "NodeNext" throws ESM related errors
Does this issue occur when all extensions are disabled?: Yes
- VS Code Version: 1.69.2
- OS Version: macOS 12.4
- TypeScript Version: 4.7.4
Steps to Reproduce:
- Create an
npmpackage. Installtypescript@4.7.4andpretty-ms. - Set
tsconfig.jsonas:
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "esnext"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": ["./src/**/*"]
}
- Add
"type": "module"inpackage.json - Following error messages are displayed by VSCode in a file with
.tsextension undersrc/:
// Cannot find module 'pretty-ms' or its corresponding type declarations. ts(2307)
import ms from "pretty-ms";
// The 'import.meta' meta-property is not allowed in files which will build into CommonJS output. ts(1470)
console.log(import.meta.url);
tscthrows no errors.- Change below values in
tsconfig.json
{
"module": "esnext",
"moduleResolution": "node"
}
- VSCode no longer throws any errors.
About this issue
- Original URL
- State: open
- Created 2 years ago
- Reactions: 26
- Comments: 28 (11 by maintainers)
Commits related to this issue
- build: Generate `.d.mts` files Fix the issue that `@kosko/env` cannot be resolved correctly when `moduleResolution=nodenext` is set in `tsconfig.json`. https://github.com/microsoft/TypeScript/issues... — committed to tommy351/kosko by tommy351 a year ago
- build: Generate `.d.mts` files Fix the issue that `@kosko/env` cannot be resolved correctly when `moduleResolution=nodenext` is set in `tsconfig.json`. https://github.com/microsoft/TypeScript/issues... — committed to tommy351/kosko by tommy351 a year ago
- Fix import issue See also https://github.com/microsoft/TypeScript/issues/50058 — committed to jamesgpearce/ts by jamesgpearce a year ago
The main issue with ESM and
nodenext/node16mode is TypeScript refusing to add.jsextensions to the generated import statements in the compiled files.The officially proposed workaround is to manually add
.jsextensions in the TypeScript sources, which is particularly graceless. So.tsextension is disallowed, yet.jsextension is required in order to point to a.tsfile - what the fuck? And where do.d.tsfiles even fit into this?It’s particularly surprising because TypeScript encourages you to use
importandexportstatements even when compiling to CJS. And when you combine this broken behavior with Node’s half-assed effort to push people off CommonJS and onto ESM, this hits real hard if you’re using monorepos/workspaces/submodules/writing a library.Frontend framework users whose build toolchain compiles TypeScript on demand will be hit next as they add the
.jsextensions and their code stops updating. Frameworks will have to special-case this kludge and it’ll become 10x harder for a newcomer to understand how JS modules work.It makes my team members develop an aversion to TypeScript, not to mention it probably got me looking pretty bad myself, because the only way to perform the otherwise trivial act of delivering an isomorphic library (i.e. one which which works out of the box in all contexts - Node, browser, ESM, CJS, pre-compiled, on-demand, framework, no framework) is walking the compiled code’s AST to add the extensions.
Gotcha; this is the expected behavior when
"x"or"y"is a CommonJS module. I’m working on a write-up of this and will just paste the draft since I think it gives enough explanationWhat is the double-
defaultproblem?In Node 16 environments, you might find yourself unable to use the default exports of certain modules in a way that you expected to.
Symptoms
A typical manifestation looks like this.
The producing module has what appears to be an ECMAScript Module default export:
The consuming module has what appears to be an ECMAScript Module (hereafter ‘ES module’ or ‘ESM’) default import:
How Does This Happen?
This occurs when someone writes a module using ES Module syntax and uses a cross-module transpiler, like TypeScript or Babel, to produce a CommonJS module, then consuming code imports that CommonJS module using ES Module syntax in an environment that uses synthetic defaults to wrap that module during interop.
How Does This Happen? Explain it like I’m not a wizard.
Absolutely. Let’s go step-by-step.
Step 1: The Module Author
The author of the
"foo"module started with some ECMAScript syntax. Let’s add a few different exports for clarity for a moment.This creates a module object that you could import with e.g.
import * as F from "foo". If you looked at this object withObject.entries, for example, you’d see something like this:barhellodefaultStep 2: The Retargeting
Now the transpiler steps in and needs to produce a CommonJS module. It makes a CommonJS module that looks like this:
This is clearly the best approximation of the module author started with. Notably, at this point, it’s kind of weird to have an explicit CommonJS module export named
default– this wasn’t normal practice prior to the existence of ES Modules. But it’s definitely the right shape; if thatdefaultexport weren’t there, it’d be pretty clear that the module author wanted thebarandhellotop-level properties as opposed to, well, anything else.Step 3: The Forgetting
Critically, what we have at this point in time is now a CommonJS module. At this point, no one knows that it was produced by a transpiler. For all anyone knows, your code, via indirection, does something like this:
This creates a “function module”, one which you use (in CommonJS) like this:
We’ll come back to this later, but let’s pretend for now that it didn’t happen.
Step 4: The Import
Now, from a native ES Module, we import “foo”, a CommonJS module:
The runtime has to decide what to do with the CommonJS
"foo"module being imported from an ESM import. The Node behavior in this situation is to wrap the CommonJS module object into thedefaultproperty of the module, as well as take all the exports of the CommonJS module as top-level exports of the ESM module.If we inspect
xat this point, it’d look like this:barhellodefaultdefault.bardefault.helloThis is subtle, since if you were just interested in
barorhello, this wrapping behavior works perfectly well. Everything appears to be working! But any function or function-like (e.g. class constructor) behavior is now only accessible through thedefault.defaultexport of the module.Step 5: Oh no.
The dreaded double
defaulthas appeared.At this point we might ask, why didn’t the runtime just “lift” everything “up” a level, rather than shove it into the
defaultproperty?The problem is that it can’t, because the module might be a “function module” like in step 4. ES Module
*imports can’t be functions, they can only be objects, so the only uniform interop solution that allows all accessing all functionality of a hypothetical function module.What About Babel, esbuild, et al, which don’t have this problem?
Some bundlers will emit a special
__esModuleproperty during Step 2 to indicate that the produced CommonJS module was started from ES Module syntax, so can be safely “lifted” back into ES Module format at runtime. They might or might not also replicate CommonJS exports into the top-level ESM object.However, this behavior isn’t universal. NodeJS itself does not check for this property, so TypeScript’s
node16module resolution system correctly models this fact and won’t assume this hoisting happens.What Should I Do Instead?
If you’re in control of the
foomodule, it’s best to avoid this confusion. You can either publish a true ESM module, or write CJS that doesn’t attempt to look like ESM.If you’re not in control of the
foomodule and are writing a true ESM module that runs in a Node16 environment, you are indeed stuck accessing the.defaultproperty of the default import.If you’re having problems with TypeScript errors and are using a bundler or other system that recognizes the
__esModuleexport, you can use"moduleResolution": "bundler"instead. In general, if your code actually does “work” in whatever your final environment is, and you’re using"moduleResolution": "node16" / "nodeenxt"in TypeScript, it’s likely that"moduleResolution": "bundler"will work instead.Ryan and I have investigated two specific instances of this double-default problem in the last two days. One was ajv; the other was react-use-websocket. Both had users reporting that after default-importing the library, they had to access
.defaultto get at the class (ajv) or function (react-use-websocket) they expected to be the simple default import. Both type definition files useexport default, both libraries only ship CommonJS, and both use the__esModulemarker. Lots of similarities.Now for the differences. ajv’s JS uses this pattern:
which means the
Ajvis both themodule.exportsand themodule.exports.default. Its typings indicate that it’s only themodule.exports.default. So a user importing from an ESM file in Node will see this:So, the typings aren’t exactly wrong, they’re just incomplete in a rather painful way. In this case, because of the interop strategy present in ajv’s JS, the sort of flag @kachkaev suggested would be safe to use.
react-use-websocket, on the other hand, only has this in their JS:
useWebSocketonly exists atmodule.exports.default, not atmodule.exports. So again, for a Node ESM importer:Here, the types are exactly right! Obviously, the author of react-use-websocket didn’t anticipate this function being loaded under Node’s ESM interop algorithm (and probably with good reason, since it’s intended for front-end use), but that’s a bit beside the point. A flag that pairs this
defaultproperty with a Node ESM default import would not be safe to use here, and there are surely other libraries that fit this same pattern.The problem is that we can’t tell the difference between these two cases without inspecting the actual JavaScript, which would completely defeat the point of having declaration files in the first place, and be very costly in terms of memory and compile time. Like Ryan, I’m sympathetic to this problem, but I 100% believe that if we added a flag to enable this kind of loose behavior, everyone would turn it on, library authors would use it as an excuse not to fix their definitions, and we’d never be able to remove it and fix the source of the problem.
I’m facing the same issue in these two PRs:
Here are some third-party packages that have problems with default exports when
"moduleResolution": "NodeNext"or"moduleResolution": "Node16":ajvstyled-componentsalgoliasearchnext/link,next/document,next/image, etc.react-slick@mui/icons-material/*react-gtm-module@emotion/cachereact-html-parserslugifyAs a temp solution, I’ve managed to do this:
Although it‘s true that the problem is within the packages, I wonder if it is possible to create a new compile option which could be used during the transition period? Something like
allowSyntheticDefaultImports, but for a different use case.When switching a project to
"moduleResolution": "NodeNext", it’s much easier to negotiate one extra line intsconfig.jsonthan a lot ofimport _x from "x"hacks. Waiting for upstream fixes may take a while and slow down migration to ESM.Maybe I’m a bit confused on what your actual runtime environment is. If it’s actually Node 16+, then this code legitimately does not work and the
_X as unknown as typeof _X.defaultworkaround is just going to result in runtime errors, because the top-level object won’t have the function behavior that is only present atX.default. I guess the implicit proposal is to bring over the properties, but not the call signatures?It’s going to be a nightmare if this flag gets common usage, because every single day someone will see a module declaration that says
import it with
Maybe if it’s named
allowImportingObjectsFromCommonJSESMInteropModuleButNotFunctionSignaturesI’d feel comfortable shipping it, but the behavior just appears broken on its face and it’s awkward to explain why we’d ship something that just helps module authors paper over their definitions being misconfigured instead of surfacing that problem right away.Certainly there are going to be modules which don’t have top-level function behavior and thus aren’t particularly affected, but I’d argue this actually makes the situation worse, since it effectively 100% hides a configuration error. In the future I think someone might reasonably ask for a
--noInteropImportsflag and discover that npm is full of modules with configuration errors that we could be stamping out today instead by correctly identifying these problems.The community wouldn’t catch up because they wouldn’t even realize they’re behind.
This is resolved as of
typescript@4.9.4and VS Code version 1.74.3. Closing this issue.Isn’t
nodenextsupposed to treat.tsas ESM when"type": "module"is set in package.json? That looks like what the bug is here, settingmoduletoesnextshouldn’t have been necessary.Looking at the comparison table between
bundlerandNodeNextin https://github.com/microsoft/TypeScript/pull/51669, I’m not sure ifbundleris as desired asNodeNextin a bunch of cases. It still enables extensionless imports,*.tsimports and directory index imports, which need to be banned for compatibility with ESM. When starting a greenfield project, it’s best to avoid these from the onset.The only problem with
"moduleResolution": "NodeNext"is the ugliness ofconst X = _X as unknown as typeof _X.default, which is an artifact of imperfect community typings. What downsides do you see in adding a flag that would allow switching toNodeNextbut avoid usingas typeofhack? It can be removed in a couple of years when the community has caught up.Not sure if same issue but with
nodenextmy code seems to transpile but shows errors in editor. I made a super simple sandbox here.Basically this code won’t work w
moduleResolutionset tonodenext.My current workaround is to add dozens of lines such as
declare namespace 'ajv'in my code for about half of my modules - presumably cjs ones. Calling - as in my example:Makes the editor code go away but now code doesn’t transpile correctly (results in things like ajv[‘default’][‘default’])
What is baffling is IF editor says error then its correct and if it says no error then its not. I kept thinking that my TS linter and my
tsccode were using different config but they do not - the fact that the sandbox also doesn’t work seems to indicate some weird error.Yes, that’s what the issue is here. When
"module": "NodeNext"and"moduleResolution": "NodeNext"are set, VSCode starts throwing above errors (might be because it starts treating the target as commonjs? not sure). Runningtschowever on the.tsfiles is successful and throws no errors. Is this a bug or am I missing some configuration?This kind of error pretty much always indicates that the types use
export defaultwhen they should useexport =instead. That’s the case for fuse.js: https://arethetypeswrong.github.io/?p=fuse.js%406.6.2. I put up a fix: https://github.com/krisk/Fuse/pull/731.I guess big.js is on DefinitelyTyped but probably has the same problem. Will take a look at that next.
Edit: big.js fix is up https://github.com/DefinitelyTyped/DefinitelyTyped/pull/66163
Leaving a note for the users of these two libraries as they seem to trigger this kind of error too with
"moduleResolution": "NodeNext":big.jsandfuse.js.Sample code:
The errors:
Hmm looks like the issue got resolved only for certain third-party packages (the ones I had in my project).
👋 @sagargurtu! How did
typescript@4.9.4fix the issue for you? I tried removing theas typeofhack in https://github.com/kachkaev/njt/pull/186/commits/2696db0a519e2e990d9c3e0b69113b4e580b4d2f (part of https://github.com/kachkaev/njt/pull/186) but this madetscfail as before.