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

npm default export is incorrectly typed for clsx #19096

Closed
pting-me opened this issue Apr 28, 2023 · 20 comments
Closed

npm default export is incorrectly typed for clsx #19096

pting-me opened this issue Apr 28, 2023 · 20 comments
Labels
invalid what appeared to be an issue with Deno wasn't node compat

Comments

@pting-me
Copy link

pting-me commented Apr 28, 2023

Describe the bug

npm:* default exports are incorrectly typed. Packages that are exported as default are assumed to have a default property. The plugin will claim a type error when there are no actual runtime errors. Furthermore, trying to appease the plugin error by using the default property will actually result in runtime errors.

To Reproduce

  1. Create a file script.ts
import clsx from 'npm:clsx';

console.log(clsx("hello", "goodbye"));
  1. Cache dependencies, notice the following error:
This expression is not callable.
  Type 'typeof import("file:///Users/pting/Library/Caches/deno/npm/registry.npmjs.org/clsx/1.2.1/clsx")' has no call signatures.deno-ts(2349)
  1. Try running deno run script.ts, notice there are no errors at runtime

  2. Try appeasing the compiler:

console.log(clsx.default("hello", "goodbye"));
  1. Notice runtime error.

Expected behavior

Expected plugin parsing behavior to match runtime behavior.

Screenshots

Screen Shot 2023-04-27 at 9 54 50 PM

Versions

vscode: 1.77.3 deno: 1.33.0 extension: 3.17.0

@pting-me
Copy link
Author

pting-me commented May 5, 2023

It looks like the file it's trying to resolve doesn't exist in my file system.

It's trying to resolve {path-to-cache}/deno/npm/registry.npmjs.org/clsx/1.2.1/clsx.

If I explicitly set @deno-types to .../clsx/1.2.1/dist/clsx.js it seems to work fine.

My main point of confusion is why deno itself does not raise any red flags, but it's throwing compiler errors here.

@dsherret dsherret changed the title npm default exports are incorrectly typed npm default exports are incorrectly typed for clsx May 11, 2023
@dsherret dsherret transferred this issue from denoland/vscode_deno May 11, 2023
@dsherret dsherret changed the title npm default exports are incorrectly typed for clsx npm default export is incorrectly typed for clsx May 11, 2023
@dsherret dsherret added bug Something isn't working correctly node compat labels May 11, 2023
@not-my-profile
Copy link
Contributor

not-my-profile commented Jun 30, 2023

I also just ran into this bug twice with two other packages (postcss-nesting and windicss). So this bug appears to be more general and less specific to clsx.

The following should type check:

import postcssNesting from "npm:[email protected]";
postcssNesting();

but deno check fails with the error message:

error: TS2349 [ERROR]: This expression is not callable.
  Type 'typeof import("file:///home/martin/.cache/deno/npm/registry.npmjs.org/postcss-nesting/11.3.0/dist/index.d.ts")' has no call signatures.
postcssNesting();
~~~~~~~~~~~~~~

Likewise the following should type check:

import Processor from "npm:[email protected]";
const processor = new Processor();

but deno check fails with the error message:

error: TS2351 [ERROR]: This expression is not constructable.
  Type 'typeof import("file:///home/martin/.cache/deno/npm/registry.npmjs.org/windicss/3.5.6/index.d.ts")' has no construct signatures.
const processor = new Processor();
                      ~~~~~~~~~

My main point of confusion is why deno itself does not raise any red flags, but it's throwing compiler errors here.

Deno is no longer using proper TypeScript but has started using a fork to support npm specifiers, see #16332.

@pting-me
Copy link
Author

Yeah I meant to use clsx as an example but I guess the title got renamed.

Deno is no longer using proper TypeScript but has started using a fork to support npm specifiers, see #16332.

That's good to know. I assume that's causing the discrepancy because VSCode will not be able to use that fork, is that correct?

I guess in the end most people are using URL imports nowadays but certain packages (Vite in particular comes to mind) work with the npm:* counterpart but not on esm.sh.

@not-my-profile
Copy link
Contributor

not-my-profile commented Jun 30, 2023

I assume that's causing the discrepancy because VSCode will not be able to use that fork, is that correct?

No that's not correct. You can just install the Deno extension for VSCode, which uses the language server that's part of the deno binary, which in turn uses the forked TypeScript version. (Opening a directory with a deno.json file in VSCode will then prompt you to switch to the Deno language server.)

I guess in the end most people are using URL imports nowadays

Deno 1.28 has stabilized npm compatibility in the sense that you no longer need to use --unstable to use npm: specifiers, which makes the bug described by this issue quite severe in my opinion, since you'd expect a "stabilized" implementation to support a feature as common as default exports.

@dsherret
Copy link
Member

dsherret commented Jun 30, 2023

This has to do with Deno's Node module resolution implementation for type checking and is not related to the fork (the fork is for multiple globalThis symbols).

In this case, these packages have incorrect types, but TypeScript's Node module resolution can sort it out, but Deno's can't at the moment. Edit: No, this was me not understanding this properly.

https://arethetypeswrong.github.io/?p=clsx%401.2.1
https://arethetypeswrong.github.io/?p=postcss-nesting%4011.3.0
https://arethetypeswrong.github.io/?p=windicss%403.5.6

@not-my-profile
Copy link
Contributor

Ah, thanks for clarifying that. I am curious: is the node module resolution implemented in Rust? Could you point me to where it's implemented?

@dsherret
Copy link
Member

It's called here from TypeScript:

const resolved = ops.op_resolve({
specifiers,
base,
});

Then rust code starts here (see call to resolve_non_graph_specifier_types):

fn op_resolve(

@not-my-profile
Copy link
Contributor

Ah, thanks! Are you sure that this problem only occurs with packages that have incorrect types? If so what's wrong with the @denotest/types-export-default module of #19669?

@dsherret
Copy link
Member

@not-my-profile it's a cjs dts file that has a default export.

@dgreensp
Copy link

dgreensp commented Feb 1, 2025

I'm finding that trying to use default exports from NPM packages often causes type errors, but simply copying the type declaration file to a local file and referencing it with @deno-types fixes the problem. I don't think it can be completely explained by "wrong types."

Here's an example:

import analyzerPlugin from "npm:[email protected]";

analyzerPlugin();

The type error is: Type 'typeof import("file:///Users/davidgreenspan/Library/Caches/deno/npm/registry.npmjs.org/rollup-plugin-analyzer/4.0.0/index.d.ts")' has no call signatures.

The code runs fine, however.

Are The Types Wrong reports: "? Missing export =". This seems like more of a warning. And why should the JS code cause a type error? Doesn't Deno just look at the type and ignore the JS, when it comes to type-checking?

The types say:

declare const analyzer: (options?: AnalyzerOptions) => Plugin;
export default analyzer;

And as mentioned, when I copy the contents of index.d.ts to a local file and reference it with @deno-types, the error goes away. So I think something weird is going on.

@dsherret dsherret added invalid what appeared to be an issue with Deno wasn't and removed bug Something isn't working correctly labels Feb 1, 2025
@dsherret
Copy link
Member

dsherret commented Feb 1, 2025

This is actually not a bug. The same thing happens in typescript when using NodeNext module resolution from ESM (which Deno is):

Image

In the case of clsx, the types of newer versions are correct now, so upgrading to a new version should fix the issue.

With [email protected], it has incorrect types and its default export is described as not callable with NodeNext module resolution from ESM:

Image

I'm finding that trying to use default exports from NPM packages often causes type errors, but simply copying the type declaration file to a local file and referencing it with @deno-types fixes the problem.

The reason it works is because when you copy to a local file, that file is considered ESM instead of CJS (because it's not in a cjs npm package anymore) and so TypeScript treats the default export as an ESM default export instead of a CJS with a default named export.

I know, this whole ESM importing CJS situation really sucks, but the types are wrong for those packages and they don't properly describe themselves. Luckily https://arethetypeswrong.github.io is helping and more and more packages are having correct types over time.

@dsherret dsherret closed this as not planned Won't fix, can't repro, duplicate, stale Feb 1, 2025
@dgreensp
Copy link

dgreensp commented Feb 1, 2025

Thanks for the quick reply.

I hear your argument that Deno's decision not to support these NPM packages is ideologically correct.

However, I think the way you are imagining this will play out is just not realistic.

  • I'm pretty much the best person you're going to find to advocate for Deno's stances on things, even when they are difficult to understand and require extra work. I love Deno, I've been using it for maybe four years now, and I've dutifully dealt with every issue I've come across. I'm a very experienced developer, and I love arcane, pedantic stuff. I'm writing onboarding docs right now about how to use Deno, because I'm a founder/CTO hiring engineers. This situation is so complex and subtle, though, I don't think I can explain it or justify it to other devs. Deno advertises NPM support that just works, and CommonJS support that just works. Reducing the situation to what it means in practice, for developers, I basically have to say that NPM support is pretty dicey, you are going to get stuck, and you are not going to know what to do.

  • Out of four Rollup plugin NPM packages I am using, by four different authors, one has "correct" types, two have "masquerading" issues, and one is the one we looked at above. Two of the packages were last published 4 years ago and are quite popular. So, 1) These typing inadequacies are very widespread, maybe even the norm. 2) People seem to be able to use these packages anyway. 3) Filing PRs against all these packages, including packages that have not been updated in years, but work fine for most people, is not necessarily going to work.

  • If I were to go and try to file PRs with these packages:

    • It's hard to parse out what change is actually required, from the long descriptions on arethetypeswrong
    • I don't know how to test a change to the types of a CommonJS NPM package and see if Deno likes the new types better
    • It's not clear to me if these changes would cause issues for non-Deno TypeScript users (maybe some testing needed there), or be better code

I am actually a bit skeptical that the suggestion to "export = an object with a default property" is common practice.

Reading what the TypeScript handbook has to say about writing types for CommonJS modules:

In CommonJS you can export any value as the default export, for example here is a regular expression module:

module.exports = /hello( world)?/;

Which can be described by the following .d.ts:

declare const helloWorld: RegExp;
export default helloWorld;

If we look in [email protected], we see:

module.exports = plugin;

and in index.d.ts:

declare const analyzer: (options?: AnalyzerOptions) => Plugin;
export default analyzer;

(It's true that the JS also executes plugin.default = plugin, but... so? That shouldn't break things at the type level. Maybe it improves compatibility for some system, somewhere, who knows, but I don't see how it should cause a problem.)

So, while I may have missed something in citing the TypeScript handbook, that's that I would expect a package author to say: That they followed the best practices for writing types for CommonJS modules. What can I say to that? And even if I can be convinced those types are just plain wrong, we still have to convince the entire world.

Maybe there could be some kind of flag or simpler workaround than what I'm doing. It doesn't seem like Deno needs to be confused about what is happening with this package.

@dgreensp
Copy link

dgreensp commented Feb 1, 2025

I see that the types I cited require esModuleInterop: true, and the Handbook does say that without that, you'll need export =. (Edit: It doesn't say to export = something with a default property anywhere, though.) From what I remember, esModuleInterop: true is the norm in the Node world and has been for a long time.

@dgreensp
Copy link

dgreensp commented Feb 1, 2025

To add a little more information, not to take up anyone's time, but perhaps for the benefit of someone coming across this thread in the future...

There is good info in the DefinitelyTyped README, and DefinitelyTyped uses arethetypeswrong (so packages whose types come from DefinitelyTyped will not have arethetypeswrong errors).

The README does say:

When the implementation package uses module.exports = ..., the DefinitelyTyped package should use export =, not export default. (Alternatively, if the module.exports is just an object of named properties, the DefinitelyTyped package can use a series of named exports.)

In the FAQ it says (which seems slightly contradictory):

A package uses export =, but I prefer to use default imports. Can I change export = to export default?

Like in the previous question, refer to using either the --allowSyntheticDefaultImports or --esModuleInterop compiler options.

Do not change the type definition if it is accurate. For an npm package, export = is accurate if node -p 'require("foo")' works to import a module and export default is accurate if node -p 'require("foo").default' works to import a module.

In the case of rollup-plugin-analyzer, both of those ways of importing the module in Node work.

But, it does say earlier in the document to use export =. And while there is no example of export = of an object with a default property, I suppose that would be necessary for backwards compatibility? So that require("rollup-plugin-analyzer").default continues to work? Maybe that is the reason. (Edit: Well, that argument seems like it would apply more to the JS than the types, for a package that is JS with an index.d.ts file.)

@dsherret
Copy link
Member

dsherret commented Feb 1, 2025

I see that the types I cited require esModuleInterop: true, and the Handbook does say that without that, you'll need export =. (Edit: It doesn't say to export = something with a default property anywhere, though.) From what I remember, esModuleInterop: true is the norm in the Node world and has been for a long time.

See this setup and output:

Image

esModuleInterop: true creates an exports.default property that Node.js won't have as the default export. So if a CJS module is described as having a default export, what that means is it has a default property on the default export.

If I change the call site to be incorrect like so, there's an error when type checking and at runtime:

Image

So the types need to accurately describe what they are at runtime. If they say export default then that means they're saying they have a default property on the exports as shown above when they're CJS and imported from ESM.

When the implementation package uses module.exports = ..., the DefinitelyTyped package should use export =, not export default.
...
Do not change the type definition if it is accurate. For an npm package, export = is accurate if node -p 'require("foo")' works to import a module and export default is accurate if node -p 'require("foo").default' works to import a module.

Yes, that's correct. This is the way that types should describe themselves to be accurate. A big confusion comes from the fact that TypeScript supports export default in files that get transpiled to CJS and the declaration file says its export default, but it's not a default export... it's a default property on the default export.

@dgreensp
Copy link

dgreensp commented Feb 1, 2025

I see, so the default property on the exports object of the package's index.js is actually being used:

const plugin = (opts = {}) => {
  // ... snip ...
};

Object.assign(plugin, { plugin, analyze, formatted, reporter });

plugin.default = plugin; // <--- this line

module.exports = plugin;

When I use @deno-types to apply the package's own index.d.ts, which contains this:

declare const analyzer: (options?: AnalyzerOptions) => Plugin;
export default analyzer;

Note that this package is written in JS, not TS, and the author attempted to satisfy a range of different types of consumers. (There is even a "module" property in the package.json, I just noticed, that points to an ESM entrypoint module.js which uses a real export default statement! But I think Deno doesn't look at the "module" property.)

What I don't understand is, when I don't specify @deno-types...

import analyzePlugin from "npm:[email protected]";

analyzePlugin();

...what is Deno doing with the index.js and index.d.ts files that leads to a type error (but not a runtime error)? The JS code assigns an object to module.exports that has a default property. What more does Deno want? Why is Deno even looking at the JS code? When I command-click on analyzePlugin, it takes me to the types, so Deno is definitely finding the types.

I know you said it had something to do with erasing the CommonJS status of a module, but I still don't understand why setting a module's types to its own types fixes the type error:

// @deno-types="https://unpkg.com/[email protected]/index.d.ts"
import analyzePlugin from "npm:[email protected]";

analyzePlugin(); // works

I think if I understood why this JS code and these types work at runtime but don't type-check (without the extra directive), that would help. Because it actually seems like the developer covered their bases.

For now, I'm probably going to just keep having a copy of this module's types checked into my repository, because they are short, and it enables me to work around some other issues at the same time.

@dgreensp
Copy link

dgreensp commented Feb 1, 2025

Specifically, when Deno has the types for an NPM package, I would expect it to use those types for type-checking and not the JS. I remember that modules in Deno have a "code slot" and a "type slot," as described here. Deno "will look at the slots for the dependency, offering [the TypeScript compiler] the type slot if it is filled before offering it the code slot."

@dsherret
Copy link
Member

dsherret commented Feb 1, 2025

but I still don't understand why setting a module's types to its own types fixes the type error

When you use @deno-types, you make the resolution go to a new location instead of inside the npm package. Since https://unpkg.com/[email protected]/index.d.ts is considered ESM to Deno (because it's a remote https module) it works because that's an ES module with export default analyzer, and ESM importing ESM considers default exports as default exports.

Specifically, when Deno has the types for an NPM package, I would expect it to use those types for type-checking and not the JS

It's using the types for type checking—Deno is not looking at the index.js file to figure out the types. The index.d.ts file in rollup-plugin-analyzer is considered a CJS module because the package.json does not contain "type": "module" entry and its extension is .d.ts. index.d.ts incorrectly describes the index.js as having a default property on the default export. The information at https://arethetypeswrong.github.io/?p=rollup-plugin-analyzer%404.0.0 saying the types are wrong is correct and the maintainers of that library should fix the issue.

There is even a "module" property in the package.json, I just noticed, that points to an ESM entrypoint module.js which uses a real export default statement! But I think Deno doesn't look at the "module" property.

The "module" property does not impact whether the index.d.ts file is considered CJS or ESM. That's done by the "type" property (ex. "type": "module").

@dgreensp
Copy link

dgreensp commented Feb 3, 2025

index.d.ts incorrectly describes the index.js as having a default property on the default export

As I've said, there is a default property on the default export (I went to the trouble of commenting it with a big arrow, // <--- this line, above) and you've confirmed Deno is not looking at index.js. So, your point needs to be rephrased in terms of the .d.ts file and the importing .ts file. I think what you are saying is, there are two interpretations of a .d.ts file (as of TypeScript 4.7, in 2022?), and in the CJS interpretation, export default means something different and incompatible with import foo from in an ESM module.

I decided to see what happens with importing this module in a TypeScript project, without Deno.

I based my tsconfig.json on yours:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "NodeNext",
    "moduleResolution": "nodenext",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "lib": ["ESNext", "DOM"]
  }
}

I installed the package and put this in main.ts:

import plugin from "rollup-plugin-analyzer"

console.log(plugin());

This works perfectly. TypeScript emits the right code to do the necessary CJS interop.

But renaming the file main.mts does cause the same type error as in Deno.

To make main.mts work, I need to change the code to:

import plugin from "rollup-plugin-analyzer"

console.log(plugin.default());

I'm learning this is by design (of Node's and TypeScript's latest ESM/CJS interop story), as ugly as it is.

And this code works at runtime, too! Thanks to that line where the .default property is set in the package's JS. So actually, the package developer is doing something a bit smart here, simulating one of these newfangled CommonJS modules.

I say newfangled because... something major changed about what ESM, CJS, and interop even mean in TypeScript, once Node embarked on trying to support ESM modules. I think I'm putting the historical picture together, now.

esModuleInterop comes from a different time (2018). Back then, this was a CommonJS module, using best/common practices, with a default export:

module.exports = function foo() { return 123; }

Meanwhile, you have "ES modules" like this output from the TypeScript compiler:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = foo;
function foo() { return 123; }

It sets __esModule to true because it's a transpiled ES module, so it follows different conventions from CommonJS.

The whole point of interop, at the time of esModuleInterop, was you should be able to consume modules in both of these styles: CommonJS or transpiled ES module. Including default exports. You should be able to import foo from..., and it should just work. That was the point. There are some caveats from this era about how package authors might want to be conservative (in case someone isn't using esModuleInterop), but generally, everyone agreed on these conventions, including Babel, which many people used to transpile their (non-TypeScript) ES modules.

If an index.d.ts file had an export default, TypeScript did not assume that that meant the JS was emitted in TypeScript/Babel/ES style. Instead, TypeScript emitted code at import time to check the __esModule module property, to support both styles. That's still generally how people use TypeScript. That's why main.ts works.

Then Node apparently decided that it would do its own thing with ESM/CommonJS interop, when it shipped native ESM. It didn't buy into the TypeScript/Babel mapping from ESM to CJS where you have exports.default. Instead, it made exports the default export, following more conventional human-written CommonJS style. So now, TypeScript and Babel's method of emitting default exports gets kind of mangled when it passes through Node's CJS interop, and you have this whole "default property of the default object" situation. TypeScript responded with this whole .mts/.cts thing, and, counterintuitively, in this new world, decided to just assume all CommonJS modules with default exports are in TypeScript/Babel style, and get rid of runtime import shims. If you really want to write types for a classic, non-transpiled CommonJS module, you can use export =, which was always technically more compatible, but for different reasons.

In a bizarre turn of events, ESM-transpiled-to-CJS—the bread and butter of tools like TypeScript—which used to be on the "ESM" side of ESM/CJS interop side, with the CJS side consisting of non-transpiled, human-written CJS modules, is now considered to be on the "CJS" side of interop, with native ESM on the other side. The interop with idiomatic CJS is gone in the new module modes, after being the de facto standard, and part of TypeScript's emit, for years. Pushing the narrative that package authors just had the "incorrect" types the whole time is misguided at best.

Based on my research, the Node situation is widely considered to be a mess, and developer sentiment is pretty negative about the behavior of .mts files. The general feeling is "no one asked for this," and it doesn't solve any problems anyone had. It's just teams trying to align with each other, and propagating the consequences of some questionable decisions.

For example, consider these Hacker News comments:

No, you were using non-standard ESM modules (compiled to CommonJS defined by babel) Typescript recently added support for ESM compatible with node.js see "module": "node16"[1][2]

The Whole ESM saga is clusterfuck, not much better than python 2 -> 3 migration. Large node.js codebases have no viable path to migrate, and most tools still cannot support ESM properly[3]. Stuff is already breaking because prolific library authors are switching to ESM.

As someone that maintain large part of TS/JS tooling in my day job, I absolutely despise decisions made by node.js module team. My side projects are now in Elixir and zig because these communities care about DX.

Yeah it's pretty ugly. This whole thing is a prime example of those cases in which maintainers for mostly arbitrary reasons decide on something and then absolutely ignore all the massive negative feedback they get for this.

They'll cite some nebulous technical reasons of why it has to be this way, but if you offer a PR that actually solves the issue that the community complains about, they'll reject it.

In this case they decided that tsc won't transpile imports. They just did and it's "policy" and it can't be changed. It doesn't matter if it is awful for compatibility, developer experience, etc. It's just the policy. Issues will be closed. And no, even an optional flag to transpile imports is off the table, even if you write the PR for it.

It is complicated but most user anger should be directed to node.js module group[1]. TS is forced to follow node.js standard.

Or these comments from the maintainer of a bundling tool, when asked to add support for generating .d.cts files:

While that's indeed the position of the TS team now, for the past 6+ years, ESM .d.ts files were all that existed and were pretty much without issue. I don't think this is going to be a priority to address here

I don't find TS's changes here to be particularly compelling (as it causes no real issue anyhow) and no one else has invested time to land this, so this issue will probably persist.

Developers who encounter issues importing CJS from ESM with the new TypeScript behavior get a range of responses from commenters, with some echoing your take that all the responsibility belongs to package authors writing wrong types, but there is also acknowledgment that the old standard of interop has been lost, the new behavior is quite different, and the situation is not ideal:

(OP) My understanding is the module "node16" introduced in TS 4.7 is meant to enable having the project use ESM while still being interoperable with CJS. Just like Node.js 16 is. Thus the ESM project could use CommonJS dependencies.

You are on the right track, this is related to Typescript being overly protective about importing CJS in an ESM file.

But wait, this worked before? Why does a typescript bump mean the default import suddenly doesn't work?

Axios is declaring the default, but it is also assigning the module.exports to the axios instance as well!

So it works as expected in Javascript. But they never told Typescript about it (missing an export = axios) so Typescript complains.

But you know it works that way, so you can do something like

import axios, { Axios } from 'axios';

(axios as unknown as Axios).get('https://google.com');

[...]

TLDR this is definitely a known confusion, and is working as intended in Typescript. The actual issue (if one exists at all) would be in Axios' type definitions. Hopefully one of these three work-arounds will get you back up and running

I think the problem is more than that.

The issue I found is that esModuleInterop is not working in this case.

[...]

I think that is behaving as expected... [explains]

thank you for the detailed explanation. I'm afraid this will make the full transition to ESM long, as many packages that work well with TS and module=commonjs will not get updated soon...

(RyanCavanaugh) Unfortunately we don't have any good ways to force external packages to be correctly configured, and the overall design of node modules means that range of misconfigurations we're able to account for isn't very accommodating.

The reason is because axios "lied" about what it exports in the declaration. [...]

I find it strange that TS knows that that there is a .default with the correct type on the export but can't use it with this syntax like it used to with esModuleInterop and "moduleResolution": "Node".

I agree with [...] that this is a major friction point, and breaks the existing ecosystem. This should at least be handled by another optional interop mode. [...]

Zooming out in order to not miss the forest for the trees...

Deno wants to provide a good experience of using NPM packages.

Node did some crazy stuff that kind of breaks the ecosystem, and TypeScript is trying to play along as best it can, because what is it supposed to do? (Though I think they probably had some better options than what they settled on, and maybe they will still ship some kind of improved interop. It's possible people don't run into, and report, this issue very often because not many people use .mts—because why would they?) Tools like arethetypeswrong are a way of trying to make the most of a bad situation, taking all the Node and TypeScript stuff as given. Deno is better at backwards compatibility than Node and wouldn't have a Python 3 situation, I don't think.

Anyway, it sounds like Deno is giving its developers the .mts experience, not the .ts experience. So actually, you don't get (what I would still consider) industry-standard CJS interop. You get degraded interop, because changes to Node that make NPM packages harder to use (unless everyone goes and changes them all!), that Node developers can choose not to opt into by not naming their files .mjs, and non-Deno TypeScript developers don't necessarily have to deal with, are forced on you as a Deno developer.

Deno isn't Node, and it actually has control over how interop works. It's not in the position the TypeScript team is in, or the position that DefinitelyTyped is in. It doesn't have to just rationalize and live with other people's decisions. Deno can choose TypeScript flags, it can have shims, it can do whatever it wants.

At the very least, let's not rewrite history or risk gaslighting people. TypeScript wrote new rules for --module node16 and --module nodenext, and now they are being applied to NPM packages that were written before those rules came out, and the solution is supposed to be to change the packages. That's not backwards-compatible. Yes, export = was technically always more compatible than export default for describing the types of module.exports =, but if you Google for how to write types for a CommonJS package, you will still be told to use export default for module.exports =, as I linked above, and then that, technically, this requires esModulesInterop to be true everywhere the code will run. No caveats are given on that particular page about how .mts modules won't be able to consume these files, regardless of the value of esModulesInterop. And I had no idea Deno was interpreting my files as .mts files.

Here's a good TypeScript module theory page, to leave it here as a reference for people.

There's a particularly interesting part a reader might skip to, about how TypeScript mis-predicted (not that they could have known!) how ESM would interoperate with CJS when it finally landed, resulting in today's misalignment:

[...]

The upside of this “author ESM, output anything” strategy was that TypeScript could use standard JavaScript syntax, making the authoring experience familiar to newcomers, and (theoretically) making it easy for projects to start targeting ESM outputs in the future. There are three significant downsides, which became fully apparent only after ESM and CJS modules were allowed to coexist and interoperate in Node.js:

  1. Early assumptions about how ESM/CJS interoperability would work in Node.js turned out to be wrong, and today, interoperability rules differ between Node.js and bundlers. Consequently, the configuration space for modules in TypeScript is large.

[...]

I'll close this very long comment (thanks for reading) by saying, I think it's important to consider not just one narrative and notion of "technical correctness" here, but multiple viewpoints, and also what developers want and what's good for developer experience. Also, if you want a bunch of developers to go do a bunch of work updating packages, you have to be able to validate their lived experience. Going around projecting a reality where people are just wrong and therefore need to fix their packages might influence a few people, but I think a more grounded approach is more effective. And as I mentioned, Deno is in a position where it could take any of a variety of stances on the issue.

I welcome technical and historical corrections, and engagement with some of the themes here.

@dsherret
Copy link
Member

dsherret commented Feb 3, 2025

esModuleInterop is not a feature Node resolution supports. Deno aligns as closely with the rules of Node resolution as possible and in Deno .ts files are considered ESM when they're not in a CJS npm package because we started as an ESM first runtime. We don't have this mode where files with esm imports/exports are actually commonjs under the hood, which is a huge source of this confusion and is a mode that's going away over time as more and more people switch to ESM.

but there is also acknowledgment that the old standard of interop has been lost

We're not going to invent yet another way of doing Node resolution. That would make the world a worse place.

At the very least, let's not rewrite history or risk gaslighting people.

I'm not sure what you mean by rewriting history. Node resolution was implemented without esModuleInterop support. TypeScript implemented a way to accurately describe it over time.

you will still be told to use export default for module.exports =, as I linked above

That is incorrect/outdated information and it's not properly describing the CJS files as having a export = for all kinds of module resolution. It needs to use export = and not export default to properly describe itself. I opened microsoft/TypeScript-Website#3322 to fix that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
invalid what appeared to be an issue with Deno wasn't node compat
Projects
None yet
Development

No branches or pull requests

4 participants