TypeScript: Problems with Node.js `--experimental-detect-module`
Node.js’s --experimental-detect-module flag poses a problem for TypeScript. If a future release of Node.js enables it by default, there is little we can do to model its semantics in our own module system under --module nodenext, which could lead to a confusing developer experience for TypeScript and JavaScript authors relying on tsc or editor language features in projects configured for Node.js.
This is fundamentally a TypeScript problem, and I don’t know that the severity merits lobbying Node.js to change course. However, I still thought it was important to document and share as feedback.
The problem
- When TypeScript reads a type declaration file, it needs to know the true module format of the JavaScript file it represents.
- The contents of a declaration file do not always provide a reliable indication of the true module format of the corresponding JavaScript file.
When a user compiles a TypeScript file like
export default 0;
they can vary the module format of the output JavaScript file by changing the --module flag, but the output declaration file always looks like
declare const _default: 0;
export default _default;
So, that declaration file text on its own is ambiguous. It could represent either of these JavaScript files:
export default 0;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = 0;
or scripts in AMD or SystemJS format.
For TypeScript users who want to run their code in Node.js, it’s important that TypeScript can distinguish between these two cases, because it affects whether those users can access that module with certain types of imports, and what type should be assigned to the names imported from that module:
import z1 = require("export-default-zero"); // Error if resolved file is ESM
import z2 from "export-default-zero";
z2.default; // 0 if resolved file is CJS
// undefined if resolved file is ESM
Currently, Node.js’s own module format detection algorithm resolves this ambiguity for us. The file extension of the declaration file tells us the file extension of its JavaScript counterpart, which tells us how Node.js will interpret that file’s module format:
| Declaration extension | JavaScript extension | Module format |
|---|---|---|
.d.mts |
.mjs |
ESM |
.d.cts |
.cjs |
CJS |
.d.ts |
.js |
Determined by package.json |
These constraints allow us to make a safe assumption about the format of every JavaScript file represented by a declaration file. For example, when we see a .d.ts file, we know it represents a .js file whose format is determined by its package.json scope—if we find that package.json and see that it does not include a "type" field, we assume the JavaScript file contains CommonJS syntax. If that assumption is wrong, the user’s problem is that their JavaScript file is misconfigured for usage in Node.js—they don’t have a problem with TypeScript.
--experimental-detect-module prevents us from being able to make safe assumptions about the module format of .js files whose package.json scope does not define a "type". The flag makes Node.js detect the module format of those files by reading their contents, but TypeScript can’t do the same in the parallel world of declaration files due to the syntax ambiguity discussed earlier.
Possible mitigations
Resolve and read JavaScript files when declaration files are ambiguous
TypeScript avoids reading, or even checking for the existence of, JavaScript files when declaration files are found first. The declaration files currently tell the compiler everything it needs to know about the presence and contents of JavaScript files, so there’s no point in spending extra memory and file I/O reading JavaScript files. If that changes, we could potentially resolve ambiguity by looking at JavaScript content. This would come with a performance penalty. Additonally, multiple team members expressed discomfort at the prospect of giving up the invariant of declaration files being ultimate sources of truth for the compiler. As things stand now, this is the mitigation we’re least likely to pursue.
Eliminate declaration file ambiguity going forward
We could begin changing our declaration file emit format to ensure that module formats are not ambiguous in the future. This is something I strongly advocate for, but it isn’t a solution on its own, because so many existing packages are already ambiguous, and many will never update.
Assume ambiguous files are CommonJS (i.e., do nothing)
If Node.js made --experimental-detect-module the default in the future, we would advocate strongly that all packages define a "type" in their root package.json to avoid ambiguity. (We would likely consider mandating this when compiling local projects under --module nodenext.) We could rely on this becoming best practice for new and actively maintained packages, and assume that packages lacking a "type" field were published before --experimental-detect-module, when such a lack of a "type" implied CommonJS.
Type imports of ambiguous files as permissively as possible
It might be possible to do some type system trickery to make something like this work:
import z1 from "export-default-zero";
z1; // Type: AmbiguousModule<0> ~= 0 & { default: 0 }
z1.default; // Type: 0
This would be an attempt to get out of the way and never issue a false positive error, at the expense of missing some real errors.
Allow users to assert the format of ambiguous imports
In combination with some other behavior as the default, we could allow users to resolve the ambiguity themselves, perhaps with an import attribute or some central configuration.
About this issue
- Original URL
- State: open
- Created 7 months ago
- Comments: 24 (11 by maintainers)
That’s something we might consider for a user’s project package.json, but the real issue for us is the thousands of existing npm packages that have no
"type"and will never be updated to get one. We can’t reasonably error on those, but we still need to know what format their.jsfiles are.We asked
npmabout this when we were working on--experimental-default-typeand the answer was basically no, because the npm registry is for more than just Node stuff, and targets like browsers don’t need atypefield. They also won’t ever change an already-published package, say to add thetypefield, because of security; current consumers of old packages might be checking integrity checksums, and those are never supposed to change.Node asked
npmabout this when--experimental-default-typewas being designed, and they said that it’s a hard requirement that files aren’t modified bynpmonce they’re published, for security reasons. That’s why--experimental-default-typedoesn’t apply to folders belownode_modules, because all typeless packages would be broken under--experimental-default-type=moduleand therefore the flag would be unusable unless users did some kind of post-install transform of all their dependencies to add the missing"type": "commonjs"entries.--experimental-detect-moduledoesn’t have this issue, as code that can run as CommonJS will run as CommonJS, and so it doesn’t have anode_modulesexception. It’s also preferable to have code behave the same way whether or not it’s undernode_modules, so that the library author and the library consumer have code run the same way (at least when they’re both using the same version of Node).Actually to amend my previous comment slightly, what if:
tscis updated ASAP to start emitting unambiguous type definition files; andThis would close the edge case at the cost of performance, but only under a condition that would be rare to occur (someone relying on detection enabled by default, and who generated the type definition files using an old version of TypeScript). The vast majority of the time would be either correctly-guessed ambiguous type definition files that are CommonJS, or unambiguous type definition files.
Yes, DefinitelyTyped can be updated in bulk, which helps for a large number of old/stale but still-popular packages, but not for ones that ship their own types. I’m not sure we would want to use DT to add module format info for packages that do ship their own types (much less duplicate those types to DT), but Daniel and I did briefly mention the idea of shipping a Bloom filter encoding a set of popular npm packages that are definitely CommonJS. I forgot to mention it in my OP because it was one of the more out-there ideas. I’m not sure it would be a big improvement over “always assume CommonJS.”
I don’t think this is an issue about this new option, but rather about TS in general. More specifically, supporting for CJS, ESM, TS(CJS), TS(ESM) from a single
.jsfile is very hard.Specifically we have to do stuff like this in fastify:
This needs to be matched by a
.d.tsfile like this:With all of this,
import myFn from 'myfn'andimport { myfn } from 'myfn'would work on all the combinations of CJS+ESM+TS above.Note that this problem is so significant that multiple modules have to:
An alternative solution for module authoring is to https://github.com/isaacs/tshy and have both a CJS and ESM being built.
I do think the new option makes it worse, mostly because it exposes most developers to the same complex DX we have to do on the module authoring side.
This is something that we might want to revisit. Can you open a separate issue about this feedback?
We should not plan to unflag in 22 before the TypeScript story is sorted.
However, I think simplifying the developer experience for CJS+ESM+TS is something we should strive for, as it’s one of the major DX complaints we are hearing from Node.js users. Let’s collaborate.
@andrewbranch @DanielRosenwasser what behavior would work for you? How do you think we can solve this problem?
I don’t think this would be an option unfortunately, since having packages work locally then break on publish and install would be a really bad footgun.
It’s plausible we could unflag in 22 as @GeoffreyBooth describes. So if you do have any strong arguments against that now would be the time to mention I think.
@andrewbranch I know you likely don’t want to be implying Node.js directions here, but I’d be interested to hear if you feel this motivates Node.js not unflagging this feature in future?
For what it’s worth, within Node I implemented a
containsModuleSyntaxfunction in C++ to answer this one question very quickly. We could expose this as a public API if TypeScript might find it useful. It could be exposed before--experimental-detect-moduleis enabled by default, so therefore TypeScript could include logic like “if the Node version is < the version where module detection is enabled by default, assume CommonJS (prior behavior); else read the file and use Node’scontainModuleSyntaxto see how Node would interpret it.” The new API would exist in any version of Node where TypeScript would need it, because of the ability to assume the current behavior for prior versions.Yes this would be a performance penalty, just like detection itself is a performance penalty, which is why we encourage everyone (especially package authors) to include
"type"fields. (Or potentially even add missing"type"fields to dependencies in a post-install pass.) But it would be the most direct solution, at least until packages are updated with"type"fields and/or unambiguous type definition files.I’d in favor of that, even if we end up using one of the other options to workaround that issue, or if Node.js never makes
--experimental-detect-modulethe default; pushing for less ambiguity is a win for the whole ecosystem IMHO.