Default exports in CommonJS libraries
Time for another Tweet analysis:
Brandon McConnell
@branmcconnellApparently, I've been doing NPM package exports in TSC wrong my entire life. I usually use the ESM
export default _
if I'm doing a default export.This generated CJS output appears to break when require'd in a CJS environment, requiring a property ref of
packageName.default
instead of simplypackageName
.After much research, it appears the generally recommended fix is to use
module.exports =
in your main ESM file that TSC processes. WHAT. That's not a typo.You can use any ESM imports or syntax you need in that file, but for the CJS output file to use
module.exports
, the ESM needs to use it too. Why can't TSC convertexport default
tomodule.exports =
for the CJS target?Doing this also appears to mess with the export types, so the
.d.ts
files sometimes get emptied out unless you export your exports with both ESM and CJS syntax together, even like this:export default myFunction; module.exports = myFunction;
Has anyone else run into this? Twilight Zone moment with a simple fix? 🤞🏼
10:30 AM · Nov 14, 2023
This is a great breakdown bringing up some interesting points. Everything in it is factually correct, but the “fix” Brandon heard recommended is wrong. Let’s get into the details.
Background: CommonJS semantics
A CommonJS module can export any single value by assigning to module.exports
:
module.exports = "Hello, world";
Other CommonJS modules can access that value as the result of a require
call of that module:
const greetings = require("./greetings.cjs");
console.log(greetings); // "Hello, world"
module.exports
is initialized to an empty object, and a free variable exports
points to that same object. It’s common to simulate named exports by mutating that object with property assignments:
exports.message = "Hello, world";
exports.sayHello = name => console.log(`Hello, ${name}`);
const greetings = require("./greetings.cjs");
console.log(greetings.message); // "Hello, world";
greetings.sayHello("Andrew"); // "Hello, Andrew";
Background: Transforming ESM to CommonJS
ECMAScript modules are fundamentally different from CommonJS modules. Where CommonJS modules expose exactly one nameless value of any type, ES modules always expose a Module Namespace Object—a collection of named exports. This creates something of a paradox for translating between module systems. If you have an existing CommonJS module that exports a string:
module.exports = "Hello, world";
and you want to translate it to ESM, then transform it back to equivalent CommonJS output, what input can you write? The only plausible answer is to use a default export:
export default "Hello, world";
But if you ever add another named export to this module:
export default "Hello, world";
export const anotherExport = "uh oh";
you now have a problem. The transformed output can’t be
module.exports = "Hello, world";
module.exports.anotherExport = "uh oh";
because attempting to assign the property anotherExport
on a primitive won’t do anything useful. Remembering that default exports are actually just named exports with special syntax, you’d probably conclude that the output has to be:
exports.default = "Hello, world";
exports.anotherExport = "uh oh";
So does an export default
become module.exports
or module.exports.default
? Should it really flip flop between them based on whether there are other named exports? Unfortunately, different compilers have landed on different answers to this question over the years. tsc
took the approach of always assigning ESM default exports to exports.default
. So export default "Hello, world"
becomes:
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = "Hello, world";
(The __esModule
marker, an ecosystem standard that first appeared in Traceur, is used by transformed default imports, but isn’t relevant for the rest of this post. A more complete discussion of ESM/CJS transforms and interoperability can be found in the TypeScript modules documentation.)
This answers one of the questions posed in the tweet:
Why can't TSC convert
export default
tomodule.exports =
for the CJS target?
There’s an argument for doing that—some compilers do, or expose options to do that—but every approach has tradeoffs, and tsc
chose a lane extremely early in the life of ESM and stuck with it.
Understanding the behavior with export default
alone
With that background, it should be easy to interpret the behavior Brandon observed:
I usually use the ESM
export default _
if I'm doing a default export... This generated CJS output appears to break when require'd in a CJS environment, requiring a property ref ofpackageName.default
instead of simplypackageName
.
export default _
turns into exports.default = _
, so if someone writing a require
wants to access _
, of course they need to write a property access like require("pkg").default
to get it.
This is only a “break” if you have a specific expectation about how to translate from ESM to CJS, and as I argued in the previous section, all such expectations are fraught with inconsistencies and incompatibilities. The ESM specification didn’t say anything about interoperability with CommonJS, so tools that wanted to support both module formats kind of just made it up as they went. This is not to say that the outcome was good or even that tsc
’s decision to always assign to exports.default
looks like the best decision with the benefit of nine years of hindsight. It wasn’t mentioned in the tweet, but the exports.default
output is also problematic when imported by true ES modules in Node.js:
// node_modules/hello/index.js
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = "Hello, world";
// main.mjs
import hello from "hello";
console.log(hello); // { default: "Hello, world" }
console.log(hello.default); // "Hello, world"
Our real default import fails to bind to our transformed default export—we still need to access the .default
property! This can make it nearly impossible to write code that works both in Node.js and in all bundlers. So, while the behavior with transformed export default
in tsc
is understandable and predictable, it is indeed problematic for libraries.
The problem with adding module.exports =
to the input
Apparently, there is some conventional wisdom floating around that the way to solve this problem is to add a module.exports =
assignment to the TypeScript module, along with the export default
statement. This is a Bad Idea, and Brandon included a clue as to why:
Doing this also appears to mess with the export types, so the
.d.ts
files sometimes get emptied out unless you export your exports with both ESM and CJS syntax together, even like this:export default myFunction; module.exports = myFunction;
The key is that TypeScript doesn’t understand module.exports
in TypeScript files, so it passes that expression through verbatim to the output JavaScript file, while emitting nothing for it into the output .d.ts
file. That’s why writing module.exports
without export default
emits an empty declaration file. Including them both, the resulting output files look like:
// myFunction.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function myFunction() { /* ... */ }
exports.default = myFunction;
module.exports = myFunction;
// myFunction.d.ts
declare function myFunction(): void;
export default myFunction;
In the JavaScript, the module.exports = myFunction
completely overwrites the effect of exports.default = myFunction
. But the compiler doesn’t know that; all it sees (from a type perspective) is the default export, so that’s what it includes in the declaration file. So the JavaScript and TypeScript are out of sync: the types assert that the shape of the module is { default: () => void }
, when in reality, it’s () => void
. If a JavaScript user were to require
this module, IntelliSense would guide them to write:
require("pkg/myFunction").default();
when they actually need to write
require("pkg/myFunction")();
💥
The simplest solution
The idea that the output JavaScript should use module.exports =
instead of exports.default =
is a good one; we just need to accomplish that in a way TypeScript understands, so the JavaScript and declaration output stay in sync with each other. Fortunately, there is a TypeScript-specific syntax for module.exports =
:
function myFunction() { /* ... */ }
export default myFunction;
export = myFunction;
This creates the output pair:
// myFunction.js
"use strict";
function myFunction() { /* ... */ }
module.exports = myFunction;
// myFunction.d.ts
declare function myFunction(): void;
export = myFunction;
The JavaScript uses module.exports
and the types agree. Success!
One inconvenience to this approach is that you’re not allowed to include other top-level named exports alongside the export =
:
export interface MyFunctionOptions { /* ... */ }
export = myFunction;
// ^^^^^^^^^^^^^^^^
// ts2309: An export assignment cannot be used in a module with
// other exported elements.
Doing this requires a somewhat unintuitive workaround:
function myFunction() { /* ... */ }
namespace myFunction {
export interface MyFunctionOptions { /* ... */ }
}
export = myFunction;
Why is such a footgun allowed to exist?
Default exports, even transformed to CommonJS, are not a problem as long as the only person importing them is you, within an app where all your files are written in ESM syntax and compiled with the same compiler with an internally consistent set of transformations. Tools to compile ESM to CommonJS started emerging in 2014, before the specification was even finalized. I’m not sure anyone thought very hard about the possible long-term consequences of pushing a huge amount of ESM-transformed-to-CommonJS code to the npm registry.
TypeScript library authors are now encouraged to compile with the verbatimModuleSyntax
option, which prevents writing ESM syntax when generating CommonJS output (i.e., the compiler forces you to use export =
instead of export default
), completely sidestepping this compatibility confusion.
Looking ahead
I see two reasons to be hopeful for the future.
TypeScript 5.5, just released in beta, introduces an --isolatedDeclarations
option, which lays the groundwork for third-party tools to implement their own fast, syntax-based declaration emit. I mentioned earlier that some existing third-party tools have options that solve this problem for JavaScript emit, but don’t generate declaration files, potentially creating a worse problem. Hopefully, the next generation of JavaScript and TypeScript tooling will generate declaration files that accurately represent the transformations they do to generate their JavaScript output.
Secondly, Node.js v22 includes experimental support for being able to require
ESM graphs. If that feature lands unflagged in the future, there will be much less of a reason for library authors to continue shipping CommonJS to npm.