TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

"moduleResolution": "NodeNext" throws ESM related errors

Open sagargurtu opened this issue 2 years ago • 8 comments

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:

{
  "compilerOptions": {
    "target": "esnext",
    "lib": ["dom", "esnext"],
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  },
  "include": ["./src/**/*"]
}
  • Add "type": "module" in package.json
  • Following error messages are displayed by VSCode in a file with .ts extension under src/:
// 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);
  • tsc throws no errors.
  • Change below values intsconfig.json
{
  "module": "esnext",
  "moduleResolution": "node"
}
  • VSCode no longer throws any errors.

sagargurtu avatar Jul 26 '22 16:07 sagargurtu

It sounds like you had a misconfiguration and fixed it. I don't see a demonstrated bug here.

RyanCavanaugh avatar Jul 28 '22 03:07 RyanCavanaugh

Isn't nodenext supposed to treat .ts as ESM when "type": "module" is set in package.json? That looks like what the bug is here, setting module to esnext shouldn't have been necessary.

fatcerberus avatar Jul 28 '22 04:07 fatcerberus

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). Running tsc however on the .ts files is successful and throws no errors. Is this a bug or am I missing some configuration?

sagargurtu avatar Jul 28 '22 07:07 sagargurtu

@fatcerberus points out that #50086 is likely related FWIW.

DanielRosenwasser avatar Jul 29 '22 17:07 DanielRosenwasser

@sheetalkamat was this also fixed by your PR?

weswigham avatar Aug 01 '22 20:08 weswigham

It probably is but i didnt verify it explicitly (assuming issue is with descipencies between errors reported for vscode and tsc)

sheetalkamat avatar Aug 01 '22 20:08 sheetalkamat

The main issue with ESM and nodenext/node16 mode is TypeScript refusing to add .js extensions to the generated import statements in the compiled files.

The officially proposed workaround is to manually add .js extensions in the TypeScript sources, which is particularly graceless. So .ts extension is disallowed, yet .js extension is required in order to point to a .ts file - what the fuck? And where do .d.ts files even fit into this?

It's particularly surprising because TypeScript encourages you to use import and export statements 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 .js extensions 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.

egasimus avatar Sep 19 '22 15:09 egasimus

I am seeing an issue similar to this where the addition of NodeNext module resolution throws an incorrect error when using it with default imports.

Basically, when trying to import the long library, if you use a module resolution of Node, it will compile fine. However, if I switch to NodeNext, I get an error that says:

  node_modules/long/index.d.ts:446:1
    446 export = Long; // compatible with `import Long from "long"`
        ~~~~~~~~~~~~~~
    This module is declared with 'export =', and can only be used with a default import when using the 'allowSyntheticDefaultImports' flag

Despite the fact that allowSyntheticDefaultImports (and esModuleInterop) are set to true.

I created a super simple repro repo (reprository?) here: https://github.com/smaye81/ts-repro. You can see this behavior by running the two scripts:

npm run build:node will succeed npm run build:nodenext will fail with the above error.

I'm not sure if I'm just using a bad configuration, but I would think at the very least this error message should be fixed, right?

smaye81 avatar Sep 21 '22 23:09 smaye81

Not sure if same issue but with nodenext my code seems to transpile but shows errors in editor. I made a super simple sandbox here.

Basically this code won't work w moduleResolution set to nodenext.

import Ajv from "ajv"

export const getClient = (): Ajv => { 
    return  new Ajv() 
}

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:

import Ajv from "ajv"`

export const getClient = (): Ajv.default => { 
    return  new Ajv.default() 
}

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 tsc code were using different config but they do not - the fact that the sandbox also doesn't work seems to indicate some weird error.

cyberwombat avatar Oct 16 '22 15:10 cyberwombat

I encountered this similar problem when I want to import the default create function from zustand/vanilla while setting NodeNext for both module and moduleResolution in tsconfig.json. For now I use default-import as a temporary fix.

this doesn't work

import create from "zustand/vanilla";
export const store = create(() => ({ foo: "bar" }));

this works

import zustand from "zustand/vanilla";
import { defaultImport } from "default-import";
const create = defaultImport(zustand);
export const store = create(() => ({ foo: "bar" }));

A minimal repo to reproduce this problem: https://stackblitz.com/edit/node-vj368k?file=src/index.ts

Sec-ant avatar Oct 23 '22 17:10 Sec-ant

@zustand/vanilla has incorrect types, again, it only declares types for a cjs entrypoint for everything, and that is the result of that mismatch.

weswigham avatar Oct 23 '22 18:10 weswigham

@weswigham Thanks for the quick response, so what's the correct way to declare types for a mjs entrypoint? Forgive me if I sound stupid.

Update: If someone like me bumped into this issue and encountered similar problems, have a check on these discussions: https://github.com/hayes/pothos/issues/597 https://github.com/pmndrs/zustand/issues/1381

There're generally two things that worth noting:

  • If you have import and export statements in an esm type file, you have to check whether you have added .js extensions in those statements.
  • If the type field in package.json is commonjs or not set, typescript will recognize .js or .d.ts as cjs modules, even though they may appear in the exports: import field. You can use a lower level scoped package.json in the esm dist folder to make the files in it be recognized as es modules, or use .mjs and .d.mts extensions to explicitly set them as es modules.

Sec-ant avatar Oct 23 '22 18:10 Sec-ant

I’m facing the same issue in these two PRs:

  • https://github.com/kachkaev/njt/pull/186
  • https://github.com/blockprotocol/blockprotocol/pull/709

Here are some third-party packages that have problems with default exports when "moduleResolution": "NodeNext" or "moduleResolution": "Node16":

  • ajv
  • styled-components
  • algoliasearch
  • next/link, next/document, next/image, etc.
  • react-slick
  • @mui/icons-material/*
  • react-gtm-module
  • @emotion/cache
  • react-html-parser
  • slugify

As a temp solution, I’ve managed to do this:

-import x from "x";
+import _x from "x";
+const x = _x as unknown as typeof _x.default;

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 in tsconfig.json than a lot of import _x from "x" hacks. Waiting for upstream fixes may take a while and slow down migration to ESM.

kachkaev avatar Oct 31 '22 23:10 kachkaev

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":

  • ajv
  • styled-components
  • algoliasearch
  • next/link, next/document, next/image, etc.
  • react-slick
  • @mui/icons-material/*
  • react-gtm-module
  • @emotion/cache
  • react-html-parser
  • slugify

As a temp solution, I’ve managed to do this:

-import x from "x";
+import _x from "x";
+const x = _x as unknown as typeof _x.default;

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 in tsconfig.json than a lot of import _x from "x" hacks. Waiting for upstream fixes may take a while and slow down migration to ESM.

I would also add long to your above list. See my comment here. It seems like in addition to the issue in the package, the error message is not correct from the compiler.

smaye81 avatar Nov 01 '22 17:11 smaye81

This is resolved as of [email protected] and VS Code version 1.74.3. Closing this issue.

sagargurtu avatar Jan 21 '23 00:01 sagargurtu

👋 @sagargurtu! How did [email protected] fix the issue for you? I tried removing the as typeof hack in https://github.com/kachkaev/njt/pull/186/commits/2696db0a519e2e990d9c3e0b69113b4e580b4d2f (part of https://github.com/kachkaev/njt/pull/186) but this made tsc fail as before.

kachkaev avatar Jan 21 '23 13:01 kachkaev

Hmm looks like the issue got resolved only for certain third-party packages (the ones I had in my project).

sagargurtu avatar Jan 22 '23 17:01 sagargurtu

What's the current bug this is tracking supposed to be?

RyanCavanaugh avatar Jan 25 '23 20:01 RyanCavanaugh

I would probably summarize this bug via these steps:

  1. Set "moduleResolution": "NodeNext" in tsconfig.json

  2. Type

    import X from "x";
    
    new X();
    

    or

    import y from "y";
    
    console.log(y.z);
    

    where "x" and "y" are one of quite a few popular libraries (with CJS typings?)

  3. Observe

    Error: path/to/file.ts(42,42): error TS2507: Type 'typeof import("/path/to/project/node_modules/x")' is not a constructor function type.
    

    or

    path/to/file.ts(42,42): error TS2339: Property 'z' does not exist on type 'typeof import("/path/to/project/node_modules/y")'.
    
  4. Apply this workaround and observe no error:

    -import X from "x";
    +import _X from "x";
    +const X = _X as unknown as typeof _X.default;
    
    -import y from "y";
    +import _y from "y";
    +const y = _y as unknown as typeof _y.default;
    
  5. Remove "moduleResolution": "NodeNext" from tsconfig.json, revet the workaround and observe no error too.

kachkaev avatar Jan 25 '23 22:01 kachkaev

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 explanation


What is the double-default problem?

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 entrypoint file of "foo" module
export default function() {
  console.log("Hello, world!");
};

The consuming module has what appears to be an ECMAScript Module (hereafter 'ES module' or 'ESM') default import:

import obj from "foo";
// Expected to run, but gets TypeScript compile-time error and/or throws exception instead
console.log(obj());
// Works, but this is the .default.default property of the module??
console.log(obj.default());

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.

// Module entrypoint file
export function bar() { }
export const hello = "world";
export default { x: 42 };

This creates a module object that you could import with e.g. import * as F from "foo". If you looked at this object with Object.entries, for example, you'd see something like this:

Name Value
bar A function
hello "world"
default An object

Step 2: The Retargeting

Now the transpiler steps in and needs to produce a CommonJS module. It makes a CommonJS module that looks like this:

// CommonJS module entrypoint file
module.exports.bar = function bar() { };
module.exports.hello = "world";
module.exports.default = { x: 42; };

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 that default export weren't there, it'd be pretty clear that the module author wanted the bar and hello top-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:

// Entrypoint of CommonJS "foo" module
function MyFunction() {
}
MyFunction.bar = function() { };
MyFunction.hello = "world";
MyFunction.default = { x: 42; };
module.exports = MyFunction;

This creates a "function module", one which you use (in CommonJS) like this:

const fn = require("foo");
fn();

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:

import * as x from "foo";

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 the default property 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 x at this point, it'd look like this:

Name Value
bar A function
hello "world"
default An object with the following properties...
default.bar A function
default.hello "world"

This is subtle, since if you were just interested in bar or hello, 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 the default.default export of the module.

Step 5: Oh no.

The dreaded double default has appeared.

At this point we might ask, why didn't the runtime just "lift" everything "up" a level, rather than shove it into the default property?

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 __esModule property 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 node16 module 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 foo module, 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 foo module and are writing a true ESM module that runs in a Node16 environment, you are indeed stuck accessing the .default property of the default import.

If you're having problems with TypeScript errors and are using a bundler or other system that recognizes the __esModule export, 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.

RyanCavanaugh avatar Jan 26 '23 00:01 RyanCavanaugh

Thanks @RyanCavanaugh! Yep, you are right – the problem is with incorrect upstream typings. In an ideal world, the ecosystem would be ESM-ready and we would not have this problem at all. In practice, we have quite a few npm packages that won't be quick to patch, which complicates the migration to "moduleResolution": "NodeNext" in a general case.

This specific tsc error does not affect the runtime – otherwise const X = _X as unknown as typeof _X.default; would not work. Because of that, I wonder if it's possible to implement something like allowSyntheticDefaultImports, but for this very case. A new copiler option would allow folks to benefit from other features of "moduleResolution": "NodeNext" while waiting for fixes in typings upstream. WDYT?

kachkaev avatar Jan 26 '23 00:01 kachkaev

"moduleResolution": "bundler" (possibly plus some extra config flags?) should basically already do what you're asking for

RyanCavanaugh avatar Jan 26 '23 03:01 RyanCavanaugh

Looking at the comparison table between bundler and NodeNext in https://github.com/microsoft/TypeScript/pull/51669, I’m not sure if bundler is as desired as NodeNext in a bunch of cases. It still enables extensionless imports, *.ts imports 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 of const 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 to NodeNext but avoid using as typeof hack? It can be removed in a couple of years when the community has caught up.

kachkaev avatar Jan 26 '23 12:01 kachkaev

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.default workaround is just going to result in runtime errors, because the top-level object won't have the function behavior that is only present at X.default. I guess the implicit proposal is to bring over the properties, but not the call signatures?

What downsides do you see ...

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

export default function() { }
export function foo() { };

import it with

// OK, good
import { foo } from "my-module";
foo();

// Fails, TypeScript is clearly broken beyond repair,
// this is obviously a bug
import theFunction from "my-module";
theFunction();

Maybe if it's named allowImportingObjectsFromCommonJSESMInteropModuleButNotFunctionSignatures I'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 --noInteropImports flag 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.

RyanCavanaugh avatar Jan 26 '23 16:01 RyanCavanaugh

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 .default to get at the class (ajv) or function (react-use-websocket) they expected to be the simple default import. Both type definition files use export default, both libraries only ship CommonJS, and both use the __esModule marker. Lots of similarities.

Now for the differences. ajv’s JS uses this pattern:

module.exports = exports = Ajv;
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = Ajv;

which means the Ajv is both the module.exports and the module.exports.default. Its typings indicate that it’s only the module.exports.default. So a user importing from an ESM file in Node will see this:

import Ajv from "ajv";
Ajv;                // Actually [class Ajv], but TypeScript thinks { default: [class Ajv] }
Ajv.default;        // [class Ajv], Node and TypeScript agree!
Ajv.default.default // Still [class Ajv], TypeScript doesn't know this exists

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:

Object.defineProperty(exports, "__esModule", { value: true });
// ...
Object.defineProperty(exports, "default", { enumerable: true, get: function () { return use_websocket_1.useWebSocket; } });

useWebSocket only exists at module.exports.default, not at module.exports. So again, for a Node ESM importer:

import useWebSocket from "react-use-websocket";
useWebSocket;         // { default: [Function: useWebSocket] } Node and TypeScript agree!
useWebSocket.default; // [Function: useWebSocket] Node and TypeScript agree!

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 default property 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.

andrewbranch avatar Jan 26 '23 17:01 andrewbranch

Leaving a note for the users of these two libraries as they seem to trigger this kind of error too with "moduleResolution": "NodeNext": big.js and fuse.js.

Sample code:

import Big from 'big.js'
import Fuse from 'fuse.js'

Big(1)
new Fuse(...)

The errors:

  Type 'typeof import("/project/node_modules/fuse.js/dist/fuse")' has no construct signatures

  Type 'typeof import("/project/node_modules/@types/big.js/index")' has no call signatures. 

jstasiak avatar Jul 20 '23 17:07 jstasiak

This kind of error pretty much always indicates that the types use export default when they should use export = 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

andrewbranch avatar Jul 24 '23 16:07 andrewbranch

I have same problem for module @types/serverless if I try to use with nodeNext, I have this error :

Impossible de localiser le module 'serverless/aws' ou les déclarations de type correspondantes.ts(2307)

image

EDIT : I found how to fix. You have to do this :

import type { Functions } from 'serverless/aws.d.ts';

throrin19 avatar Dec 13 '23 09:12 throrin19