Skip to content

Commit 6fc935a

Browse files
authored
Add docs for each problem kind (arethetypeswrong#41)
* Docs WIP * Finish InternalResolutionError * Link to new docs in README, website, and CLI * Add changeset
1 parent 964f1db commit 6fc935a

29 files changed

+585
-78
lines changed

.changeset/happy-deers-marry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@arethetypeswrong/core": minor
3+
"@arethetypeswrong/cli": minor
4+
---
5+
6+
Added links to new documentation for each problem kind

README.md

Lines changed: 13 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,18 @@
11
# [arethetypeswrong.github.io](https://arethetypeswrong.github.io)
22

3-
This project attempts to analyze npm package contents for issues with their TypeScript types, particularly ESM-related module resolution issues. Currently, the following kinds of problems can be detected in the `node10`, `node16`, and `bundler` module resolution modes:
4-
5-
- **The package doesn’t ship types.** In the future, the tool may pull typings from DefinitelyTyped and check for agreement ([dts-critic](https://github.com/microsoft/DefinitelyTyped-tools/tree/master/packages/dts-critic) is prior art).
6-
- **Resolution failed.** Occurs when an import of the package (or a package subpath defined in its package.json `exports`) fails to resolve completely. This will result in a TypeScript compiler error, and may indicate that the runtime corresponding to the module resolution mode tested might fail to resolve it too.
7-
- **Untyped resolution.** Occurs when TypeScript can resolve a JavaScript file, but no type declaration file. This is a TypeScript compiler error under `noImplicitAny`.
8-
- **Types are CJS, but implementation is ESM.** In the `node16` resolution mode, TypeScript detects whether Node itself will assume a file is a CommonJS or ECMAScript module. If the types resolved are detected to be CJS, but the JavaScript resolved is detected to be ESM, this problem is raised. It is often caused by a dependency’s package.json doing something like:
9-
```json
10-
{
11-
"exports": {
12-
"types": "./index.d.ts",
13-
"import": "./index.mjs",
14-
"require": "./index.js"
15-
}
16-
}
17-
```
18-
Because there is no `"type": "module"` setting here, the `.js` and `.d.ts` file will always be interpreted as a CommonJS module. But if the _importing_ file is an ES module, the runtime will resolve to the `.mjs` file, which is unambiguously ESM. In this case, the module kind of the types misrepresents the runtime-resolved module. This tends to present the most issues for users when default exports are used. In the future, this tool may detect whether an `export default` might make this problem more severe and give a full explanation of why. In the meantime, you can read the explanation in [this issue](https://github.com/microsoft/TypeScript/issues/50058#issuecomment-1404411380). The simple fix for the example above would be to add an `index.d.mts` file dedicated to typing the `.mjs` module, and remove the `"types"` condition.
19-
- **Types are ESM, but implementation is CJS.** The reverse of the above, but with worse consequences. Because a CommonJS module cannot access an ES module without (async) dynamic import, this kind of mismatch means that TypeScript will _falsely_ belive that a CJS module is unable to import the package without dynamic import. This is more rare, but can happen with a similar situation as the above, but where the package.json has `"type": "module"`, and the importing file is a CJS module.
20-
- **Syntax is incompatible with detected module kind.** In Node, as well as in some bundlers, whether a file should be interpreted as CJS or ESM is a pure function of the file extension and the nearest ancestor package.json `type`. If these signal that the file is ESM, `module` and `require` will be `undefined`. If they signal that the file is CJS, `import` and `export` statements will be syntax errors. This problem is raised when syntax incompatible with the detected module kind is used.
21-
- **Package (or subpath) is ESM-only.** This occurs when a CommonJS importing module in `node16` resolves to an ES module (and not falsely so, as above, as far as we can tell). This is a TypeScript compiler error and will be a runtime error in Node. It’s not necessarily a defect of the package; it likely just means that the author decided to only publish ESM, leaving their CommonJS consumers without a good option.
22-
- **Resolved through a fallback condition.** In Node, when resolution of an import path hits conditional `"exports"` in a package.json, it tries to resolve with the first matching condition. If resolution fails with that condition, the process is over and the import is not resolved. TypeScript’s algorithm instead continues through the list of conditions and attempts to resolve with any other condition that matches, until resolution succeeds or the no more conditions match. This is [a TypeScript bug](https://github.com/microsoft/TypeScript/issues/50762), so its behavior should not be relied upon—even if the bug never gets fixed, results that arise from it are likely to be innacurate since the resolution process diverges from Node.
23-
- **CJS module uses a default export.** CommonJS files can indicate to bundlers that a default import should resolve to its `module.exports.default` instead of its `module.exports` by setting `module.exports.__esModule` to `true`. Node does not respect the `__esModule` marker, though, so default imports of the file in Node will have to add an additional `.default` property access in order to get to the file’s intended default export:
24-
```ts
25-
// main.mts:
26-
import doSomething from "dependency";
27-
doSomething(); // doesn't work
28-
doSomething.default(); // works
29-
```
30-
This problem is reported when a CJS module appears to use `module.exports.default`, has an `__esModule` marker, and its types use `export default`. It’s important to notice that in this case, the types and the implementation agree. Adding a `.default` is necessary, both to run in Node and to satisfy the TypeScript compiler. There are no inconsistencies here, but the pattern is discouraged since the code needed to use the import in a bundler and the code needed in Node are mutually incompatible. Instead, the library is encouraged to use `module.exports` instead of, or in addition to, `module.exports.default`. So no, the types aren’t wrong, but the package likely isn’t functioning in Node like they intended.
31-
- **Types incorrectly use a default export.** This happens when a CJS file uses `module.exports =` but its types use `export default`. The correct type declaration for `module.exports = ...` is `export = ...`. This mismatch doesn’t usually present a problem in bundlers, but for Node, TypeScript will falsely think a default import from an ESM file needs an additional `.default`, as in the example from the previous problem.
32-
33-
## Future work
34-
35-
The first things on my roadmap:
36-
37-
- More thorough explanations of problems
38-
- Support for DefinitelyTyped analysis
39-
- Official TypeScript module documentation to link to
40-
41-
Some other ideas:
42-
43-
- Use unpkg or something so it uses less data
44-
- Generate a downloadable reproduction of a problem
45-
- Analyze traces and failed lookup locations to suggest fixes
3+
This project attempts to analyze npm package contents for issues with their TypeScript types, particularly ESM-related module resolution issues. Packages can be explored via the [website](https://arethetypeswrong.github.io) or [CLI](./packages/cli). The following kinds of problems can be detected in the `node10`, `node16`, and `bundler` module resolution modes:
4+
5+
* [💀 Resolution failed](./docs/problems/NoResolution.md)
6+
* [❌ No types](./docs/problems/UntypedResolution.md)
7+
* [🎭 Masquerading as CJS](./docs/problems/FalseCJS.md)
8+
* [👺 Masquerading as ESM](./docs/problems/FalseESM.md)
9+
* [⚠️ ESM (dynamic import only)](./docs/problems/CJSResolvesToESM.md)
10+
* [🐛 Used fallback condition](./docs/problems/FallbackCondition.md)
11+
* [🤨 CJS default export](./docs/problems/CJSOnlyExportsDefault.md)
12+
* [❗️ Incorrect default export](./docs/problems/FalseExportDefault.md)
13+
* [🚭 Unexpected module syntax](./docs/problems/UnexpectedModuleSyntax.md)
14+
* [🥴 Internal resolution error](./docs/problems/InternalResolutionError.md)
4615

4716
## Contributing
4817

49-
- Understanding that the site is rather incomplete, issue reports are ok.
50-
- I’m open to someone with design chops adding some styles, but I want to keep it simple. Reach out in an issue.
18+
Contributions are welcome! Take a look at the open issues or read about [how to contribute to open source](https://opensource.guide).
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# 🤨 CJS default export
2+
3+
CommonJS module simulates a default export with `exports.default` and `exports.__esModule`, but does not also set `module.exports` for compatibility with Node. Node, and [some bundlers under certain conditions](https://andrewbranch.github.io/interop-test/#synthesizing-default-exports-for-cjs-modules), do not respect the `__esModule` marker, so accessing the intended default export will require a `.default` property access on the default import.
4+
5+
## Explanation
6+
7+
This problem does not indicate that the types are wrong, but rather that the API exposed may have compatibility problems between Node and bundlers, and will need to be consumed in Node ES modules in a way that the author likely did not intend. It occurs when both of the following conditions are true:
8+
9+
* A JavaScript file assigns `exports.default = ...` and has an `exports.__esModule = true` or similar method of setting the `__esModule` flag. (This pattern indicates that the CommonJS module has been transpiled from an ES module that used a default export.)
10+
* There is not an additional assignment to `module.exports = ...`, indicating that a compatibility pattern like `module.exports.default = module.exports = ...` was not used.
11+
12+
When these are true, imports in Node will behave differently from imports in most bundlers. Node always synthesizes a default export for CommonJS modules that points to their `module.exports` objects, whereas most bundlers use the `__esModule` property as an indicator that the default export of the CommonJS module should be the value found at `exports.default`. So for a CommonJS module like:
13+
14+
```js
15+
Object.defineProperty(exports, "__esModule", { value: true });
16+
exports.default = function f() { /* ... */ };
17+
```
18+
19+
a program with a default import like:
20+
21+
```js
22+
import mod from "pkg";
23+
console.log(mod);
24+
```
25+
26+
will result in `{ default: [Function: f] }` in Node, but `[Function: f]` in most bundlers. ([This table](https://andrewbranch.github.io/interop-test/#synthesizing-default-exports-for-cjs-modules) shows the behavior of several bundlers and the Bun runtime under different conditions.)
27+
28+
The divergence in behavior between various runtimes and bundlers can be mitigated by assigning the value intended to be the default export to `module.exports`, then additionally assigning a circular `default` property on that object back to itself:
29+
30+
```js
31+
Object.defineProperty(exports, "__esModule", { value: true });
32+
function f() { /* ... */ };
33+
module.exports = f;
34+
module.exports.default = f;
35+
```
36+
37+
This compatibility pattern has an odd effect where `f.default.default.default...` out to infinity is equal to `f`, but nonetheless, all runtimes and bundlers will bind a default import of the module to a callable `f`.
38+
39+
## Consequences
40+
41+
* Consumers in Node will need to access the module’s intended export with `mod.default` where `mod` is already a default import, which is likely not the author’s intention.
42+
* It may be impossible or inconvenient for consumers to write code that works both in Node and in bundlers.
43+
44+
## Common causes
45+
46+
This problem occurs when library authors compile ES modules that use `export default` to CommonJS with a transpiler that does not add the `module.exports` compatibility strategy discussed above (such as `tsc` itself). Library authors who ship CommonJS to npm are encouraged not to use default exports, or to apply a transform to their output that applies such a compatibility layer.

docs/problems/CJSResolvesToESM.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# ⚠️ Entrypoint is ESM-only
2+
3+
A `require` call resolved to an ESM JavaScript file, which is an error in Node and some bundlers. CommonJS consumers will need to use a dynamic import.
4+
5+
## Explanation
6+
7+
This is the “true” version of the [“Masquerading as ESM”](./FalseESM.md) problem. Whereas that problem indicates that the types are ESM even though a CJS implementation is available, this problem indicates that the types and implementation are both ESM even though CJS was requested. The errors the user will see are the same, but in this case, the error is correct about the problem that will occur at runtime. As such, this problem does _not_ indicate that “the types are wrong”; rather, it’s surfaced to highlight that certain consumers will be unable to use this module.
8+
9+
## Consequences
10+
11+
CommonJS consumers in Node will not be able to use this module without a dynamic `import()`, which introduces asynchronicity. Introducing asynchronicity into a large synchronous codebase can be a prohibitively difficult refactor and a breaking change for downstream APIs, so in practice the consequence is often that consumers will not be able to use this module at all.
12+
13+
```ts
14+
import mod from "pkg";
15+
// ^^^^^
16+
// The current file is a CommonJS module whose imports will produce 'require'
17+
// calls; however, the referenced file is an ECMAScript module and cannot be
18+
// imported with 'require'. Consider writing a dynamic 'import("pkg")' call
19+
// instead.
20+
```
21+
22+
## Common causes
23+
24+
This usually happens when a library only contains ESM after making a conscious decision not to support CommonJS. (This tool tries to be neutral on that decision, but by default shows what happens in many module resolution scenarios. The author of this tool encourages library authors to consider their constraints, understand their users, and move in the direction of ESM-only when possible.)

docs/problems/FallbackCondition.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# 🐛 Used fallback condition
2+
3+
Import resolved to types through a conditional package.json export, but only after failing to resolve through an earlier condition. This behavior is a [TypeScript bug](https://github.com/microsoft/TypeScript/issues/50762). It may misrepresent the runtime behavior of this import and should not be relied upon.
4+
5+
## Explanation
6+
7+
[Node’s algorithm](https://nodejs.org/docs/latest-v20.x/api/esm.html#resolution-algorithm-specification) for resolving package.json `"exports"` requires that resolution stop once a target filename has been tried, whether or not that file could be found. For example, consider resolving an import with conditions `types` and `import` against these `exports`:
8+
9+
```json
10+
{
11+
"exports": {
12+
".": {
13+
"types": {
14+
"foo": "./didnt-match.d.ts"
15+
},
16+
"import": {
17+
"types": "./doesnt-exist.d.ts",
18+
"default": "./exists.mjs"
19+
}
20+
}
21+
}
22+
}
23+
```
24+
25+
To simplify the specification, resolution _should_ proceed as follows:
26+
27+
1. Enter the first `"types"` condition because it matches.
28+
2. Does `"foo"` match? No. Since we haven’t tried to look up a file yet, we can exit `"types"` and continue.
29+
3. Enter the `"import"` condition because it matches.
30+
4. Try to look up `"./doesnt-exist.d.ts"` because `"types"` matches.
31+
5. Fail to find a file at `"./doesnt-exist.d.ts"`, so return a failed resolution result.
32+
33+
TypeScript reimplements this algorithm so it can understand what Node will do and find corresponding types for a given resolution, but TypeScript’s implementation has a known bug. Instead of returning a failed resolution when it can’t find `doesnt-exist.d.ts`, it continues to the next matching condition. In the example above, TypeScript would successfully resolve to `exists.mjs`.
34+
35+
This problem is raised when a resolution occurred only because of this TypeScript bug.
36+
37+
## Consequences
38+
39+
This problem almost always indicates the presence of another (deeper) problem, since a correct resolver implementation would have resulted in a [failed resolution](./NoResolution.md) or an [untyped resolution](./UntypedResolution.md), which themselves are almost always caused by a misconfiguration of the library. Often, the incorrect resolution results in a better experience for the user than a failed resolution would, which makes it difficult for TypeScript to fix the bug. The TypeScript team would want to see occurrences of this issue drop to negligible levels before fixing their resolver algorithm.
40+
41+
## Common causes
42+
43+
This issue commonly occurs in combination with [“Masquerading as CJS”](./FalseCJS.md) or [“Masquerading as ESM”](./FalseESM.md) through a package.json like:
44+
45+
```json
46+
{
47+
"name": "pkg",
48+
"main": "./index.js",
49+
"types": "./index.d.ts",
50+
"exports": {
51+
"import": "./index.mjs",
52+
"default": "./index.js"
53+
}
54+
}
55+
```
56+
57+
where an `index.d.ts` exists but `index.d.mts` does not. TypeScript first does a resolution pass only looking for types and ignoring JavaScript files, so when resolving with the `import` condition, that first pass goes something like:
58+
59+
1. `"import"` matches, so try substituting the `.mjs` extension for the type-equivalent `.d.mts`. `index.d.mts` does not exist, so **continue** (this is the bug).
60+
2. `"default"` conditions always match, so try substituting the `.js` extension for the type-equivalent `.d.ts`. `index.d.ts` exists, so us that as a resolution result.
61+
62+
But in this example, `index.d.ts` is a CommonJS module since the package.json lacks a `"type": "module"` field, whereas the runtime resolution would have been `index.mjs`, which is an ES module. So, an instance of [“Masquerading as CJS”](./FalseCJS.md) also occurred. If the library adds an `index.d.mts` file to represent the `index.mjs` file, both problems will be solved simultaneously.

0 commit comments

Comments
 (0)