Skip to content

Latest commit

 

History

History
121 lines (103 loc) · 5.73 KB

File metadata and controls

121 lines (103 loc) · 5.73 KB

🎭 Masquerading as CJS

Import resolved to a CommonJS type declaration file, but an ESM JavaScript file.

Explanation

In Node, files are determined to be ES modules or CommonJS modules by their file extension and ancestor package.json "type" field. When in --moduleResolution node16, TypeScript uses the same algorithm to determine the module format of type declaration files. A .d.mts file represents a .mjs file which is always an ES module; a .d.cts file represents a .cjs file which is always a CommonJS module; a .d.ts file represents a .js file whose module format is determined by the nearest package.json.

When a user writes an import, TypeScript needs to know whether the resolved module is ESM or CJS in order to provide accurate checking. It makes this determination based on the file extension and package.json "type" of the type declaration file it finds. This logic depends on an assumption that the type declaration file resolved by TypeScript and the JavaScript file resolved by Node actually match—an assumption that necessarily holds if the pair is generated by tsc, but can be easily violated with hand-authored declaration files or third-party build tools.

This problem indicates a violation of that assumption where the type declaration file implies that the corresponding runtime module is CommonJS, but it appears that Node will resolve to an ES module.

This problem is only raised when checking entrypoints under --moduleResolution node16, as that’s currently the only TypeScript mode that makes a module format distinction based on file extension and package.json.

Consequences

  • TypeScript will allow consumers to use a default import on the module even if one isn’t defined in the resolved types, leading to a crash at runtime. This is allowed because CommonJS modules in Node always have a default export synthesized that points to their module.exports object. When TypeScript sees a .d.ts file like:
    export declare const a: string;
    export declare function b(): number;
    and determines that it should represent a CommonJS module, the inferred structure of the corresponding JavaScript file is:
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.a = "...";
    exports.b = function b() { /* ... */ };
    That CommonJS module can be used in Node through a default import:
    import mod from "pkg";
    mod.a; mod.b();
    But if the runtime module is actually an ES module like:
    export const a = "...";
    export function b() { /* ... */ }
    then the same default import will fail to link at runtime.
  • If the types do define a default export, TypeScript will misinterpret how to access it, leading consumers to write code that crashes at runtime, and issuing an error on code that works at runtime. When TypeScript sees a .d.ts file like:
    declare function hello(): string;
    export default hello;
    and determines that it should represent a CommonJS module, the inferred structure of the corresponding JavaScript file is:
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.default = function hello() { /* ... */ };
    In Node, a default import of that CommonJS module links to the entire module.exports object, so to access the default property, it would require an extra property access:
    import mod from "pkg";
    console.log(mod); // { default: [Function: hello] }
    mod.default();
    But if the runtime module is actually an ES module like:
    export default function hello() { /* ... */ };
    then the same default import will link to the function, and an additional property access will be undefined:
    import mod from "pkg";
    mod();
    mod.default; // undefined

Common causes

This problem usually happens when a library that includes both a CJS and ESM implementation attempts to use a single .d.ts file to represent both, where the package.json does not have "type": "module", most often in a structure like:

{
  "name": "pkg",
  "exports": {
    ".": {
      "types": "./index.d.ts",
      "import": "./index.mjs",
      "require": "./index.js"
    }
  }
}

The types condition is always set by TypeScript, so the index.d.ts file will be resolved regardless of whether import or require is set. When import is set, the runtime will resolve to index.mjs, which is an ES module. But index.d.ts, due to its file extension, is interpreted as representing a CommonJS module. (The execution of tsc --declaration that produces an index.d.ts would also produce an index.js counterpart, and .js files are CommonJS in this location because the package.json does not contain "type": "module".)

A golden rule of declaration files is that if they represent a module—that is, if they use import or export at the top level—they must represent exactly one JavaScript file. They especially cannot represent JavaScript files of two different module formats. The example above needs to add a .d.mts file to represent the .mjs file, at which point the package.json can be rewritten as:

{
  "name": "pkg",
  "exports": {
    ".": {
      "import": {
        "types": "./index.d.mts",
        "default": "./index.mjs"
      },
      "require": {
        "types": "./index.d.ts",
        "default": "./index.js"
      }
    }
  }
}

or just as well:

{
  "name": "pkg",
  "exports": {
    ".": {
      "import": "./index.mjs",
      "require": "./index.js"
    }
  }
}

letting TypeScript find the corresponding index.d.mts and index.d.ts files by extension substitution.

Whatever tool produces the index.mjs file should ideally take responsibility for producing the index.d.mts file, and likewise for the .js/.d.ts pair.