Is nodenext
right for libraries that don’t target Node.js?
I started to reply to this tweet, but I think it deserves more than a hasty string of 240-character concatenations:
Matt Pocock
@mattpocockukcc @atcb in case I've got something drastically wrong.
Summary:
I think that if you transpile TS code with
NodeNext
, that will be compatible with any modern bundler (anymoduleResolution: Bundler
environment).@tpillard thinks that there are issues which mean you should transpile one export with
10:30 AM · Nov 14, 2023moduleResolution: Bundler
and another withmoduleResolution: NodeNext
if you plan to support both.
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 guide for modules.
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 enforces a corresponding module
option, which does affect emit:
moduleResolution: bundler
requiresmodule: esnext
moduleResolution: nodenext
requiresmodule: nodenext
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.
Emit differences
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 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
.
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).
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:
// @Filename: index.mts
import fs = require("dep");
has a special Node.js-specific emit:
// @Filename: index.mjs
import { createRequire as _createRequire } from "module";
const __require = _createRequire(import.meta.url);
const fs = __require("dep");
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.
Module resolution
Continuing import
/require
shows another way to frame the question. Suppose we had our input code:
// @Filename: index.mts
import fs = require("dep");
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:
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.
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.
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:
// @Filename: utils.mjs
import { sayHello } from "greetings";
sayHello(["Matt", "Tim"]);
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:
- A bundler will not be able to resolve
"greetings"
- 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
These are both absolutely possible via conditional package.json "exports"
. For example, "greetings"
could define "exports"
like:
{
"exports": {
"module": "./contains-only-say-goodbye.js",
"node": "./contains-only-say-hello.js"
}
}
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).
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.) Keep in mind that bundlers came about in large part so that modules on npm, written under the assumption that only Node.js would be able to use them, could be used in the browser, which lacked a module system at the time. Bundlers would not be doing their job if their module resolution algorithms choked on Node.js code. Conditional exports can be used to redirect a bundler, but doing this in a way that breaks the contract of the module that Node.js would see is arguably a bug.
In other words, module resolution is not an issue unless a package is doing something extra terrible with "exports"
.
Module interop
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, so suffice it to say that it is possible to have a default import:
import sayHello from "greetings";
that must be called one way in a bundler:
sayHello();
and another, unintentionally different way in Node.js:
sayHello.default();
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. (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!)
Conclusion
Matt’s advice, my usual advice, and the TypeScript documentation’s advice is that you’re usually basically fine to use nodenext
for all libraries:
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.
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.)
I think I would summarize as:
nodenext
is the right 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.- There is no TypeScript option specifically meant to enforce patterns that result in maximum cross-environment portability.
- When stronger guarantees of portability are needed, there is no substitute for runtime testing your output.
- However, a lower effort and reasonably good confidence booster would be to run
tsc --noEmit
under differentmodule
/moduleResolution
options.