Skip to content

Commit fb3fc3e

Browse files
authored
Merge pull request #276 from andrewbranch/post/commonjs-default-exports
Post: Default Exports in CommonJS Libraries
2 parents f07aa5f + a3f0984 commit fb3fc3e

File tree

2 files changed

+256
-1
lines changed

2 files changed

+256
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
---
2+
title: Default exports in CommonJS libraries
3+
description: "TL;DR: don’t use them."
4+
permalink: default-exports-in-commonjs-libraries/
5+
date: 2024-04-28
6+
layout: post
7+
tags: post
8+
---
9+
10+
Time for another Tweet analysis:
11+
12+
<blockquote class="rounded-lg bg-[var(--color-fg05)] p2 md:p4">
13+
<div class="font-grotesk">
14+
15+
**Brandon McConnell**
16+
<a class="text-textSecondary" href="https://twitter.com/branmcconnell">@branmcconnell</a>
17+
18+
</div>
19+
20+
Apparently, 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.
21+
22+
This generated CJS output appears to break when require'd in a CJS environment, requiring a property ref of `packageName.default` instead of simply `packageName`.
23+
24+
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.
25+
26+
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 convert `export default` to `module.exports =` for the CJS target?
27+
28+
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:
29+
30+
```js
31+
export default myFunction;
32+
module.exports = myFunction;
33+
```
34+
35+
Has anyone else run into this? Twilight Zone moment with a simple fix? 🤞🏼
36+
37+
<small class="font-grotesk text-textSecondary">
38+
<a href="https://twitter.com/mattpocockuk/status/1724495021745860793">10:30 AM · Nov 14, 2023</a>
39+
</small>
40+
</blockquote>
41+
42+
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.
43+
44+
## Background: CommonJS semantics
45+
A CommonJS module can export any single value by assigning to `module.exports`:
46+
47+
```js
48+
module.exports = "Hello, world";
49+
```
50+
51+
Other CommonJS modules can access that value as the result of a `require` call of that module:
52+
53+
```js
54+
const greetings = require("./greetings.cjs");
55+
console.log(greetings); // "Hello, world"
56+
```
57+
58+
`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:
59+
60+
```js
61+
exports.message = "Hello, world";
62+
exports.sayHello = name => console.log(`Hello, ${name}`);
63+
```
64+
65+
```js
66+
const greetings = require("./greetings.cjs");
67+
console.log(greetings.message); // "Hello, world";
68+
greetings.sayHello("Andrew"); // "Hello, Andrew";
69+
```
70+
## Background: Transforming ESM to CommonJS
71+
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](https://tc39.es/ecma262/#sec-module-namespace-objects)—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:
72+
73+
```js
74+
module.exports = "Hello, world";
75+
```
76+
77+
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:
78+
79+
```js
80+
export default "Hello, world";
81+
```
82+
83+
But if you ever add another named export to this module:
84+
85+
```js
86+
export default "Hello, world";
87+
export const anotherExport = "uh oh";
88+
```
89+
90+
you now have a problem. The transformed output can’t be
91+
92+
```js
93+
module.exports = "Hello, world";
94+
module.exports.anotherExport = "uh oh";
95+
```
96+
97+
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:
98+
99+
```js
100+
exports.default = "Hello, world";
101+
exports.anotherExport = "uh oh";
102+
```
103+
104+
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:
105+
106+
```js
107+
Object.defineProperty(exports, "__esModule", { value: true });
108+
exports.default = "Hello, world";
109+
```
110+
111+
(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](https://www.typescriptlang.org/docs/handbook/modules/appendices/esm-cjs-interop.html).)
112+
113+
This answers one of the questions posed in the tweet:
114+
115+
> Why can't TSC convert `export default` to `module.exports =` for the CJS target?
116+
117+
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.
118+
## Understanding the behavior with `export default` alone
119+
With that background, it should be easy to interpret the behavior Brandon observed:
120+
121+
> 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 simply `packageName`.
122+
123+
`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.
124+
125+
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:
126+
127+
```js
128+
// node_modules/hello/index.js
129+
Object.defineProperty(exports, "__esModule", { value: true });
130+
exports.default = "Hello, world";
131+
132+
// main.mjs
133+
import hello from "hello";
134+
console.log(greetings); // { default: "Hello, world" }
135+
console.log(greetings.default); // "Hello, world"
136+
```
137+
138+
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.
139+
## The problem with adding `module.exports =` to the input
140+
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:
141+
142+
> 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:
143+
>
144+
> ```js
145+
> export default myFunction;
146+
> module.exports = myFunction;
147+
> ```
148+
149+
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:
150+
151+
```ts
152+
// myFunction.js
153+
"use strict";
154+
Object.defineProperty(exports, "__esModule", { value: true });
155+
function myFunction() { /* ... */ }
156+
exports.default = myFunction;
157+
module.exports = myFunction;
158+
```
159+
160+
```ts
161+
// myFunction.d.ts
162+
declare function myFunction(): void;
163+
export default myFunction;
164+
```
165+
166+
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](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseExportDefault.md): 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:
167+
168+
```js
169+
require("pkg/myFunction").default();
170+
```
171+
172+
when they actually need to write
173+
174+
```js
175+
require("pkg/myFunction")();
176+
```
177+
178+
💥
179+
## The simplest solution
180+
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 =`:
181+
182+
```js
183+
function myFunction() { /* ... */ }
184+
export default myFunction; // [!code --]
185+
export = myFunction; // [!code ++]
186+
```
187+
188+
This creates the output pair:
189+
190+
```js
191+
// myFunction.js
192+
"use strict";
193+
function myFunction() { /* ... */ }
194+
module.exports = myFunction;
195+
```
196+
197+
```ts
198+
// myFunction.d.ts
199+
declare function myFunction(): void;
200+
export = myFunction;
201+
```
202+
203+
The JavaScript uses `module.exports` and the types agree. Success!
204+
205+
One inconvenience to this approach is that you’re not allowed to include other top-level named exports alongside the `export =`:
206+
207+
```ts
208+
export interface MyFunctionOptions { /* ... */ }
209+
export = myFunction;
210+
// ^^^^^^^^^^^^^^^^
211+
// ts2309: An export assignment cannot be used in a module with
212+
// other exported elements.
213+
```
214+
215+
Doing this requires a somewhat unintuitive workaround:
216+
217+
```ts
218+
function myFunction() { /* ... */ }
219+
namespace myFunction {
220+
export interface MyFunctionOptions { /* ... */ }
221+
}
222+
export = myFunction;
223+
```
224+
225+
## Why is such a footgun allowed to exist?
226+
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.
227+
228+
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.
229+
## Looking ahead
230+
I see two reasons to be hopeful for the future.
231+
232+
[TypeScript 5.5](https://devblogs.microsoft.com/typescript/announcing-typescript-5-5-beta/), 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.
233+
234+
Secondly, [Node.js v22](https://nodejs.org/en/blog/announcements/v22-release-announce) 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.

content/styles/main.css

+22-1
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,29 @@ pre code .line {
246246
pre code .line:last-child {
247247
display: none; /* shikiji is adding a blank line at the end for some reason */
248248
}
249+
pre.has-diff code .line {
250+
padding-left: calc(var(--rhythm) * 1.25rem);
251+
position: relative;
252+
}
249253
pre code .line.diff.add {
250-
background-color: rgb(150 255 100 / 0.15);
254+
background-color: rgb(150 255 100 / 0.2);
255+
}
256+
pre code .line.diff.add::before {
257+
content: "+";
258+
color: white;
259+
display: inline-block;
260+
position: absolute;
261+
left: calc(var(--rhythm) * 0.5rem);
262+
}
263+
pre code .line.diff.remove {
264+
background-color: rgb(255 100 100 / 0.2);
265+
}
266+
pre code .line.diff.remove::before {
267+
content: "-";
268+
color: white;
269+
display: inline-block;
270+
position: absolute;
271+
left: calc(var(--rhythm) * 0.5rem);
251272
}
252273

253274
.post-body ol,

0 commit comments

Comments
 (0)