Repository: TypeScript
Issue: Problems with Node.js `--experimental-detect-module`
Labels: Discussion

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

1. When TypeScript reads a type declaration file, it needs to know the true module format of the JavaScript file it represents.
2. 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

```ts
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

```ts
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:

```js
export default 0;
```

```js
"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:

```ts
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:

```ts
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.