Import resolved to a CommonJS type declaration file, but an ESM JavaScript file.
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.
- 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:and determines that it should represent a CommonJS module, the inferred structure of the corresponding JavaScript file is:export declare const a: string; export declare function b(): number;
That CommonJS module can be used in Node through a default import:Object.defineProperty(exports, "__esModule", { value: true }); exports.a = "..."; exports.b = function b() { /* ... */ };
But if the runtime module is actually an ES module like:import mod from "pkg"; mod.a; mod.b();
then the same default import will fail to link at runtime.export const a = "..."; export function b() { /* ... */ }
- 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:and determines that it should represent a CommonJS module, the inferred structure of the corresponding JavaScript file is:declare function hello(): string; export default hello;
In Node, a default import of that CommonJS module links to the entireObject.defineProperty(exports, "__esModule", { value: true }); exports.default = function hello() { /* ... */ };
module.exports
object, so to access thedefault
property, it would require an extra property access:But if the runtime module is actually an ES module like:import mod from "pkg"; console.log(mod); // { default: [Function: hello] } mod.default();
then the same default import will link to the function, and an additional property access will beexport default function hello() { /* ... */ };
undefined
:import mod from "pkg"; mod(); mod.default; // undefined
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.