Skip to content

esModuleInterop and allowSyntheticDefaultImports in TypeScript 6.0+ #62529

@andrewbranch

Description

@andrewbranch

Acknowledgement

  • I acknowledge that issues using this template may be closed without further explanation at the maintainer's discretion.

Comment

Related:

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

  1. needs to resolve to the module.exports of “true” CJS modules, and
  2. 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

  1. allowSyntheticDefaultImports without esModuleInterop 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. When esModuleInterop was added later, it implied allowSyntheticDefaultImports, but each option was still allowed to be set independently.
  2. esModuleInterop should not be an option; it should be our standard emit. The JavaScript ecosystem has universally agreed that an ESM default import resolves to module.exports for true CJS modules.
  3. 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):

  1. If the file we’re looking at is actually JavaScript, we can just look for __esModule.
  2. If the file is a .ts (non-declaration) file, we know we’re going to emit __esModule to the JavaScript unless it uses export =.
  3. 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 uses export default to describe a module.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.
  4. 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

  1. Deprecate esModuleInterop: false.
  2. In addition, do one of the following with allowSyntheticDefaultImports:
    1. Deprecate it too, making it always-on. Do nothing about the type-checking hole described above.
    2. 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 (with false being stricter than true).
    3. 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:
      - export declare const x: number;
      - export declare const y: number;
      + declare namespace mod {
      +   const x: number;
      +   const y: number;
      + }
      + export = mod;
      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.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions