|
| 1 | +--- |
| 2 | +title: Is `nodenext` right for libraries that don’t target Node.js? |
| 3 | +description: "Settling a Twitter debate the only way I know how: you’re both right, but not as right as me." |
| 4 | +permalink: is-nodenext-right-for-libraries-that-dont-target-node-js/ |
| 5 | +date: 2023-11-14 |
| 6 | +layout: post |
| 7 | +tags: post |
| 8 | +--- |
| 9 | + |
| 10 | +I started to reply to [this tweet](https://twitter.com/mattpocockuk/status/1724495021745860793), but I think it deserves more than a hasty string of 240-character concatenations: |
| 11 | + |
| 12 | +<blockquote class="rounded-lg bg-[var(--color-fg05)] p2 md:p4"> |
| 13 | +<div class="font-grotesk"> |
| 14 | + |
| 15 | +**Matt Pocock** |
| 16 | +<a class="text-textSecondary" href="https://twitter.com/mattpocockuk">@mattpocockuk</a> |
| 17 | + |
| 18 | +</div> |
| 19 | + |
| 20 | +cc [@atcb](https://twitter.com/atcb) in case I've got something drastically wrong. |
| 21 | + |
| 22 | +Summary: |
| 23 | + |
| 24 | +I think that if you transpile TS code with `NodeNext`, that will be compatible with any modern bundler (any `moduleResolution: Bundler` environment). |
| 25 | + |
| 26 | +[@tpillard](https://twitter.com/tpillard) thinks that there are issues which mean you should transpile one export with `moduleResolution: Bundler` and another with `moduleResolution: NodeNext` if you plan to support both. |
| 27 | + |
| 28 | +<small class="font-grotesk text-textSecondary"> |
| 29 | +<a href="https://twitter.com/mattpocockuk/status/1724495021745860793">10:30 AM · Nov 14, 2023</a> |
| 30 | +</small> |
| 31 | +</blockquote> |
| 32 | + |
| 33 | +I _mostly_ agree with Matt, and his advice earlier in the thread more or less matches what I published in TypeScript’s [Choosing Compiler Options](https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html#im-writing-a-library) guide for modules. |
| 34 | + |
| 35 | +It’s worth unpacking some of the nuances here. Something important about Tim’s position, and about how `tsc` works, is lost to the Twitter shorthand in Matt’s characterization above. `moduleResolution` doesn’t affect `tsc`’s emit, so producing two different outputs toggling that option alone would do nothing for consumers. But as Matt and Tim both know, it’s not possible to toggle _just_ `moduleResolution` between `bundler` and `nodenext`, because each of these enforce a corresponding `module` option, which _does_ affect emit: |
| 36 | + |
| 37 | +- `moduleResolution: bundler` requires `module: esnext` |
| 38 | +- `moduleResolution: nodenext` requires `module: nodenext` |
| 39 | + |
| 40 | +So at face value, the real question posed in Matt’s tweet is whether there are differences in emit between `module: esnext` and `module: nodenext` that might cause bundlers to trip over `nodenext` code. |
| 41 | + |
| 42 | +## Emit differences |
| 43 | + |
| 44 | +The most important thing to understand about `module: nodenext` is it doesn’t just emit ESM; it emits whatever format it _has_ to in order for Node.js not to crash; each output filename is inherently either ESM or CommonJS according to [Node.js’s rules](https://www.typescriptlang.org/docs/handbook/modules/theory.html#module-format-detection) and `tsc` chooses its emit format for each file based on those rules. So depending on the context, we might be asking be asking about the difference between CommonJS and ESM, or the difference between `module: esnext` and the particular flavor of ESM produced by `module: nodenext`. |
| 45 | + |
| 46 | +Bundlers are capable of processing (and typically willing to process) both CommonJS and ESM constructs wherever they appear (unlike Node.js, which _parses_ ES modules and CommonJS scripts differently), so it’s fair to say that the potential difference in module format between `module: esnext` and `module: nodenext` will not itself break bundlers (although most bundlers have more difficulty tree-shaking CommonJS than ESM). |
| 47 | + |
| 48 | +There is, however, one specifically Node.js-flavored emit construct that could derail a bundler. In `module: nodenext`, a TypeScript `import`/`require` inside an ESM-format file: |
| 49 | + |
| 50 | +```ts |
| 51 | +// @Filename: index.mts |
| 52 | +import fs = require("dep"); |
| 53 | +``` |
| 54 | + |
| 55 | +has a special Node.js-specific emit: |
| 56 | + |
| 57 | +```js |
| 58 | +// @Filename: index.mjs |
| 59 | +import { createRequire as _createRequire } from "module"; |
| 60 | +const __require = _createRequire(import.meta.url); |
| 61 | +const fs = __require("dep"); |
| 62 | +``` |
| 63 | +
|
| 64 | +I _assume_ this won’t work in most bundlers, though some have built-in Node.js shims, so I could be proven wrong. But this is a clear case where `module: nodenext` would allow Node.js-specific code to be emitted; it’s just not something someone is likely to write by mistake. |
| 65 | +
|
| 66 | +## Module resolution |
| 67 | +
|
| 68 | +Continuing `import`/`require` shows another way to frame the question. Suppose we had our input code: |
| 69 | +
|
| 70 | +```ts |
| 71 | +// @Filename: index.mts |
| 72 | +import fs = require("dep"); |
| 73 | +``` |
| 74 | +
|
| 75 | +and in order to avoid the potential for Node.js-specific emit to crash a user’s bundler, we decided to produce two outputs—one for Node.js and one for bundlers. When switching to `module: esnext` and `moduleResolution: bundler`, our input code errors: |
| 76 | +
|
| 77 | +<pre class="shiki dark-modern" style="background-color: #1F1F1F; white-space: normal"><code style="color: #D4D4D4"> |
| 78 | +ts1202: Import assignment cannot be used when targeting ECMAScript modules. Consider using 'import * as ns from "dep"', 'import {a} from "dep"', 'import d from "dep"', or another module format instead. |
| 79 | +</code></pre> |
| 80 | +
|
| 81 | +So the question isn’t fully answered just by looking at the transforms applied by these `module` modes; we also need to ask whether valid input code in `module: nodenext` is also valid in `module: esnext` and `moduleResolution: bundler`. If there are compilation errors, that’s a strong signal that the output code will be a problem. |
| 82 | +
|
| 83 | +Beyond this one emit incompatibility, what other compilation errors are possible? Earlier in the Twitter thread, the theory was that module resolution is the problem. Let’s define exactly what that would mean. Imagine we have an output file like: |
| 84 | +
|
| 85 | +```js |
| 86 | +// @Filename: utils.mjs |
| 87 | +import { sayHello } from "greetings"; |
| 88 | +sayHello(["Matt", "Tim"]); |
| 89 | +``` |
| 90 | +
|
| 91 | +Suppose we generated this file from a TypeScript input that compiled error-free under `module: nodenext`, meaning that TypeScript did its best to model how Node.js will resolve the specifier `"greetings"`, found type declarations for the resolved module, and verified that the usage of the API was correct. The theory that this analysis does not provide a similar level of confidence that this code will work in a bundler due to module resolution differences presupposes that at least one of these outcomes is possible: |
| 92 | +
|
| 93 | +1. A bundler will not be able to resolve `"greetings"` |
| 94 | +2. A bundler will resolve `"greetings"` to a module that has a sufficiently different shape, such that, if the types for that module were known, TypeScript would report an error for the usage of the API |
| 95 | +
|
| 96 | +These are both **absolutely possible** via conditional package.json `"exports"`. For example, `"greetings"` could define `"exports"` like: |
| 97 | +
|
| 98 | +```json |
| 99 | +{ |
| 100 | + "exports": { |
| 101 | + "module": "./contains-only-say-goodbye.js", |
| 102 | + "node": "./contains-only-say-hello.js" |
| 103 | + } |
| 104 | +} |
| 105 | +``` |
| 106 | +
|
| 107 | +This would direct bundlers to one module and Node.js to another, each having a completely different API. In this case, it would be impossible to write a single import declaration that works in both contexts, and TypeScript could be used to catch an error like this by type checking the input code under multiple `module`/`moduleResolution`/`customConditions` settings. But this is terrible, terrible practice! I don’t think I’ve ever seen this in a real npm package (and I have looked at a _lot_ of package.jsons in the last year). |
| 108 | +
|
| 109 | +Indeed, the _only_ difference between `moduleResolution: bundler` and the CommonJS `moduleResolution: nodenext` algorithm in TypeScript is import conditions, and every resolution that can be made in the ESM `moduleResolution: nodenext` algorithm will be made the same way in `moduleResolution: bundler`, with the exception of where import conditions cause them to diverge. If there are other differences in the _real_ resolution algorithms of bundlers and Node.js, they are not reflected in TypeScript’s algorithm, so additional checking with TypeScript won’t help. In other words, module resolution is not an issue unless a package is doing something extra terrible with `"exports"`. |
| 110 | +
|
| 111 | +## Module interop |
| 112 | +
|
| 113 | +The last possible incompatibility (that TypeScript can catch) is how modules of different formats interoperate with each other. I’ve written about this [extensively elsewhere](https://www.typescriptlang.org/docs/handbook/modules/appendices/esm-cjs-interop.html), so suffice it to say that it is possible to have a default import: |
| 114 | +
|
| 115 | +```ts |
| 116 | +import sayHello from "greetings"; |
| 117 | +``` |
| 118 | +
|
| 119 | +that _must_ be called one way in a bundler: |
| 120 | +
|
| 121 | +```ts |
| 122 | +sayHello(); |
| 123 | +``` |
| 124 | +
|
| 125 | +and another, unintentionally different way in Node.js: |
| 126 | +
|
| 127 | +```ts |
| 128 | +sayHello.default(); |
| 129 | +``` |
| 130 | +
|
| 131 | +even though both module systems resolved to the same module. If you are writing a library, compiling to ESM, checking with `module: nodenext`, and find yourself needing to write `.default()` on something you default-imported, there is a possibility that your output code [will not work in a bundler](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/CJSOnlyExportsDefault.md). (There is also a possibility that the type declarations for the library are incorrectly telling TypeScript that `.default` is needed, when in fact it is either not needed or [not present](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseExportDefault.md)!) |
| 132 | +
|
| 133 | +## Conclusion |
| 134 | +
|
| 135 | +Matt’s advice, my usual advice, and the [TypeScript documentation’s advice](https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html#im-writing-a-library) is that you’re _usually basically_ fine to use `nodenext` for all libraries: |
| 136 | +
|
| 137 | +> Choosing compilation settings as a library author is a fundamentally different process from choosing settings as an app author. When writing an app, settings are chosen that reflect the runtime environment or bundler—typically a single entity with known behavior. When writing a library, you would ideally check your code under _all possible_ library consumer compilation settings. Since this is impractical, you can instead use the strictest possible settings, since satisfying those tends to satisfy all others. |
| 138 | +
|
| 139 | +But Tim is right that this is imperfect. It’s definitely possible to construct examples that break. In reality, breaks only occur in the face of packages that are behaving badly. (Unfortunately, a fair number of packages behave badly.) |
| 140 | +
|
| 141 | +I think I would summarize as: |
| 142 | +
|
| 143 | +- `nodenext` is the write option for authoring libraries, because it prevents you from emitting ESM with module specifiers that _only_ work in bundlers but will crash in Node.js. When writing conventional code, using common sense, and relying on high-quality dependencies, its output is usually highly compatible with bundlers and other runtimes. |
| 144 | +- There is no TypeScript option specifically meant to enforce patterns that result in maximum cross-environment portability. |
| 145 | +- When stronger guarantees of portability are needed, there is no substitute for runtime testing your output. |
| 146 | +- However, a lower effort and reasonably good confidence booster would be to run `tsc --noEmit` under different `module`/`moduleResolution` options. |
0 commit comments