-
Notifications
You must be signed in to change notification settings - Fork 13k
Description
Acknowledgement
- I acknowledge that issues using this template may be closed without further explanation at the maintainer's discretion.
Comment
Related:
- 6.0 Deprecation Candidates #54500
- https://www.typescriptlang.org/docs/handbook/modules/appendices/esm-cjs-interop.html
Background
These options come into play when compiling
import x from "./transpiled-cjs";
console.log(x);
to CommonJS, where ./transpiled-cjs
is also a CommonJS module. With both options off, the emit of the above is:
const transpiled_cjs_1 = require("./transpiled-cjs");
console.log(transpiled_cjs_1.default);
If "./transpiled-cjs"
was compiled from ESM syntax with a default export, this works perfectly—a default import resolves to a default export. But now, let’s say we install an npm package that exposes the following CommonJS module:
module.exports = function sayHello() {
console.log("Hello");
};
How are we supposed to import this? It has no default
property, so cjs_1.default
is undefined
. We could perhaps use a namespace import:
import * as x from "cjs-pkg";
because it compiles to
const x = require("cjs-pkg");
While this works at runtime, it breaks ESM semantics, because a namespace import in real ESM always resolves to a namespace-like object with no signatures. If we want to be able to use a CommonJS module that defines a single, non-namespacey module.exports
from a transpiled ES module, it seems like we have to be able to use a default import for it. So in summary, a transpiled default import
- needs to resolve to the
module.exports
of “true” CJS modules, and - needs to resolve to the
module.exports.default
of ESM-transpiled-to-CJS modules.
To achieve this, the JavaScript community converged long ago on the convention of defining an module.exports.__esModule
property in files that were transpiled from ESM to CJS:
Object.defineProperty(module.exports, "__esModule", { value: true });
exports.default = 42;
This allows compilers to implement a default import that checks the __esModule
flag in order to determine whether to return module.exports
or module.exports.default
:
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const transpiled_cjs_1 = __importDefault(require("./transpiled-cjs"));
console.log(transpiled_cjs_1.default);
This improved emit is the primary thing esModuleInterop
does. The option allowSyntheticDefaultImports
goes hand in hand with it—when enabled, the type checker is aware that sometimes a default import can resolve to module.exports
, and will try to figure out whether that’s the case by looking at the types of the module being imported.
import x from "cjs-pkg";
// with allowSyntheticDefaultImports, the compiler will resolve types for cjs-pkg
// and try to guess whether the JS file has `__esModule` defined or not. In other words,
//
// return (mod && mod.__esModule) ? mod : { "default": mod };
// ^^^ ^^^^^^^^^^^^^^^^^^
// will we take this branch ^ at runtime, or ^ this one?
Some sweeping conclusions based on these facts
allowSyntheticDefaultImports
withoutesModuleInterop
or vice versa should never have been allowed.allowSyntheticDefaultImports
came first in order to support type checking behavior that would reflect the runtime behavior of Babel and Webpack. WhenesModuleInterop
was added later, it impliedallowSyntheticDefaultImports
, but each option was still allowed to be set independently.esModuleInterop
should not be an option; it should be our standard emit. The JavaScript ecosystem has universally agreed that an ESM default import resolves tomodule.exports
for true CJS modules.- Correct type checking behavior depends on accurately determining whether the target module is a CJS module with
__esModule
. Because TypeScript usually analyzes declaration files instead of the JavaScript that will get loaded at runtime, it’s possible for us to be misled on both whether a module is ESM or CJS and whether it has__esModule
.
Guessing whether a module has an __esModule
marker
When we’re misled about whether a declaration file represents an ES module or CJS module, that’s always the result of a configuration error of the kind that https://arethetypeswrong.github.io was made to diagnose and fix. But when we’re trying to figure out whether a CJS module has an __esModule
marker based on its declaration file, there is some ambiguity of our own making. These are the current rules for determining whether a module was transpiled from ESM syntax (i.e., whether it has an __esModule
marker):
- If the file we’re looking at is actually JavaScript, we can just look for
__esModule
. - If the file is a
.ts
(non-declaration) file, we know we’re going to emit__esModule
to the JavaScript unless it usesexport =
. - If a declaration file has an
export default
, it is assumed to be compiled from ESM. This is a heuristic, but a decently good one—it holds for any.d.ts
/.ts
pair that was generated by us. It could go wrong if a hand-written declaration file usesexport default
to describe amodule.exports.default
member of a true CJS module, but that case is fairly rare—it merits being documented in DefinitelyTyped guidance if it’s not already. - If a declaration file explicitly declares
__esModule
in the typings, the JS is assumed to have it. I’ve never seen this, but it makes sense.
Otherwise, we assume the module is a true CJS module without __esModule
. This is where the real ambiguity lies. A TypeScript file like
export const x = 42;
export const y = 43;
will emit as
// .js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.x = 42;
exports.y = 43;
// .d.ts
export declare const x = 42;
export declare const y = 43;
If these compilation outputs are imported into another project using esModuleInterop
, the rules stated above result in the compiler assuming that the JS does not have an __esModule
marker, thereby incorrectly allowing a default import:
import mod from "x-and-y";
console.log(mod.x, mod.y); // 💥 TypeError: Cannot read properties of undefined (reading 'x')
In other words, when we don’t know whether a default import is valid or not, we silently allow it in order to be more permissive. The only way to plug this hole is to stop using esModuleInterop
/allowSyntheticDefaultImports
, but that also prohibits default imports when we definitely know they would work.
Proposed solutions
- Deprecate
esModuleInterop: false
. - In addition, do one of the following with
allowSyntheticDefaultImports
:- Deprecate it too, making it always-on. Do nothing about the type-checking hole described above.
- Change
allowSyntheticDefaultImports
to mean “allow default imports when it’s ambiguous whether they’re safe.” Default imports that are definitely (or almost definitely, based on the heuristics above) safe would always be allowed. The option would essentially become a strictness option (withfalse
being stricter thantrue
). - Deprecate it and make ambiguously-safe default imports always an error. It could catch some false positives on hand-authored declaration files, but using named imports, a namespace import, or
import x = require("mod")
are all viable workarounds on the importer side, and we could make a mass change to DefinitelyTyped to fix the most common false positives on the exporter side:This declaration file style is currently disallowed by a longstanding and arguably misguided lint rule in DefinitelyTyped, but we could make this the new preference for disambiguating transpiled CJS from true CJS along with an automated one-time bulk migration.- export declare const x: number; - export declare const y: number; + declare namespace mod { + const x: number; + const y: number; + } + export = mod;
My preference: test (iii) on top repos and consider. I’m torn between (i) and (ii) otherwise, as I think the type checking hole is unfortunate, but it’s also longstanding and doesn’t get many complaints, so it would be a shame for it to stand in the way of deprecating an option with a name as bad as allowSyntheticDefaultImports
.