They key thing is that require(esm) shipped in node 22, and is being back ported to node 20.
Since Node 18 maintenance ends in less than a month, this means all Node versions will have good esm support, including for consuming esm-only libraries (which until recently did not work!)
This is noted somewhat in the article, but basically is the whole story to me. Its now possible to stop doing CJS libraries entirely. And with that, I don't see why we would do CJS at all.
(sadly at work, at this point we already have done the useless work of migrateming to ESM for no good reason other than having libs that were ESM only)
The main argument in that post doesn't even hold; using ESM, you can always using dynamic imports:
await import( someExpression )
Besides, static analysis and tree shaking completely break down if modules impose side effects from being required, which is one of the main gripes of Python as well. ESM completely alleviates that.
That forces dynamic imports (and is horrible ergonomics as discussed in the post). CJS requires can be statically analyzed to figure out whether a require can be static, as is done e.g. in all bundlers.
…if you’re so inclined to do things exactly as you did in the past. I’m pretty sure bundlers will even transform that into an ordinary module import.
The cases where you actually need dynamic imports are few and far between. Are we actually talking about engine limitations here, or is it just a few snowflakes that insist on creating loggers like this?
require("debug")("acme")
What is so particularly pretty about that is beyond me.
In a limited way due to side effects from the require function.
Reading that rant, the author did not seem to take much time to understand the rationale behind ES modules: “ yet for some completely unclear reason, ESM proponents decided to remove that property” is just pure ignorance.
I'm doing server code (which I guess is the main use case for nodejs), I just COPY the project folder in the docker and ship that. No need for bundling, shaking and other annoyance.
Tree-shaking also happens in GC heap space (both in the server and in the browser): V8 and SpiderMonkey and JavaScriptCore will all eventually treeshake unused code out of memory entirely. The ESM format was designed to allow that. Modules actually only reference each other through weak references and things like import * build proxy objects of weak references designed for tree-shaking.
Depending on how your application is structured, an ESM version of a server-only Node application may still benefit from the performance optimizations of the server being able to do dead code elimination at runtime.
You'd likely still benefit from using TypeScript and transpiling that to plain JavaScript (preventing a lot of subtle bugs), and tree shaking to minimize your Docker image size (yielding faster deployments).
In case you ever get to that point; have a look at Unbuild (https://github.com/unjs/unbuild). It's what Vite uses for their own builds, and really painless to set up.
Haha I'm probably getting too old or something but I really cannot see what this tool does exactly and why I'd need it. I have been working on what is now a fairly large typescript/nodejs monorepo (over 1M LoC) for the past ~13 years, and we can simply build it all with tsc -b, and COPY it in the docker image as I said, it's nice and simple and works great.
There are still maintained and supported older versions of node.js by the likes of Debian, Ubuntu, Enterprise Linuxes. They backport security fixes and such during a longer extended window but are unlikely to port ESM-require support. It may not be relevant to you and obv nobody's obliging you to also support those users, just saying it's not that binary.
Huh, oddly no mention of native browser support in the form of <script type=“module”> and importmaps! To me, that felt like the last platform to offer ESM support.
I also feel like browser support “feels” more official than support from bundlers.
It's certainly nice, and makes possible Vite's very quick reloads of developer code, but even Vite still makes (ESM) bundles for performance reasons, both for production (with everything, using Rollup) and for dev (with just the external deps, using ESBuild).
My understanding is that HTTP 2 pipelining was supposed to make requesting lots of ESM modules from the same site fast enough that bundling could be consigned to history, but for various reasons it didn't work as intended and it was ultimately removed from most browsers.
HTTP protocol changes were never, ever, going to make bundling unnecessary. No one actually involved in the things said it would, or thought it would. It was less-deeply-involved people misunderstanding things that led to that popular impression.
HTTP/2’s better multiplexing helps a little, but you’ve still completely got the waterfall problem: so you have at an absolute minimum of overhead the dependency graph depth times the round-trip time—frequently multiple seconds.
HTTP/2 Server Push could have improved it in some regards, as it can in theory break out of the waterfall problem, but in practice it would have required much more complex servers, and was missing important pieces so that it was completely useless anyway (a way for the client to tell the server what resources it has cached), and they eventually just removed it all round rather than inventing and implementing the missing pieces, with which it still would have been a good deal less efficient to execute than bundling.
Minifying and bundling is just better, no matter what.
> My understanding is that HTTP 2 pipelining was supposed to make requesting lots of ESM modules from the same site fast enough that bundling could be consigned to history, but for various reasons it didn't work as intended and it was ultimately removed from most browsers.
HTTP/2+ Pipelining works pretty well. (As others mentioned, it was HTTP/2 Server Push that didn't quite survive in the wild, which could have helped additional scenarios.)
I've been personally moving towards Vite's dev approach for Production (just external deps and big libraries with esbuild) and little to no bundling in dev (only external deps that don't run out-of-the-box as ESM with an importmap). A handful of small "local" files and couple big shared libraries works very well in Production in my experience.
Oh yeah I assumed so. I just thought it was weird to not mention it in an article about ESM becoming the standard JS modules format. Even if it’s not the most performant use of ES modules, I still think it’s certainly the most official “signal” of support to have it in all major browsers.
That would be expected for a browser environment though! I don’t want any module my browser imports to silently import something off the filesystem without telling me.
while i agree with that, i don't quite understand why this is an issue if the main index.html file is from the local filesystem too. the filesystem should only be off limits if the main file is loaded from a website.
i suppose the problem is mixing local and remote sources. i don't know if blocking remote sources from loading local ones is enough or even easy to do. if not then that would explain why local needs to be blocked.
Most systems come with Python today, so starting an HTTP server for local testing is often a one-liner like `python -m http.server`. Anyone already working with Node has access to one-liners like `npx http-server`. (Deno and Bun also have one-liners.)
It is not that easy. You need to open console and navigate to the project directory first. While with ordinary HTML files all you need is to start typing its name in address bar (or simply never close the tab).
This isn't ESM's fault, this is the CORS security model that applies to all JS in most browsers. Some of the browser's "grandfather in" some looser restrictions for file:// origin JS files from file:// origin HTML files, with Firefox being the least restrictive I'm aware of, but you can trigger CORS blocks in all of them for JS files of many types, not just ESM. ESM just wound up on the other side of the expiration of "grandfathered in" loose restrictions for CORS checks in most browsers.
Sure, there's a learning curve to running even a one-liner localhost HTTP server, but when I was learning HTML the first time there were all sorts of strange learning curves (many of which aren't even relevant anymore).
Sure, it makes it harder to distribute things like Twine apps, but there are known workarounds (bundle all the scripts into the HTML file) that Twine already does. (I'd love to see a new well-supported "HTML app container" format/standard to download/distribute HTML apps safely. It seems unfortunate that PWAs went so deep into Service Worker mania and the simple ideas like I should be able to ZIP a folder of HTML and JS files, rename it to something like myapp.pwa and it "just work" kind of got lost in several shuffles. Sure, Service Worker-based file auto-updating is nice when it works, but it is so complicated and sometimes I just want a dumb ZIP-like file users can double-click.)
On a side note, I recently experimented with native TS support in Node.js and it felt like magic: no need for extra flags; debugging and watch just work; types are simple to re-use between browser and server. Erasable syntax seems like the way forward, can't wait for it to land in browsers.
Same here - it's really quite something. The future is looking good!
That being said, there are currently still some hurdles. Necessity for explicit file-extensions in the imports is definitely the big offender (it's invalid typescript syntax without the allowImportingTsExtensions-flag).
The trend is definitely clear though, most devs want ESM, most devs want types; it's just a matter of time until the ecosystem adapts. I suppose for types to finally land in the browser, TC39 will have to undergo the "progress is one funeral at a time"-principle, which will probably take another while.
> With the rise of Vite as a popular modern frontend build tool, many meta-frameworks like Nuxt, SvelteKit, Astro, SolidStart, Remix, Storybook, Redwood, and many others are all built on top of Vite nowadays, that treating ESM as a first-class citizen.
> As a complement, we have also testing library Vitest, which was designed for ESM from the day one with powerful module mocking capability and efficient fine-grain caching support.
> CLI tools like tsx and jiti offer a seamless experience for running TypeScript and ESM code without requiring additional configuration. This simplifies the development process and reduces the overhead associated with setting up a project to use ESM.
> Other tools, for example, ESLint, in the recent v9.0, introduced a new flat config system that enables native ESM support with eslint.config.mjs, even in CJS projects.
I think the author and I must have very different definitions for the word "ready"
So you need a bundler, a linter and a test runner. How’s that any different from the experience in other languages where you need a compiler, a linter and a test runner?
I was working on a legacy CSJ project, and I tried to upgrade an OIDC library, but the newer version would only work with ESM. I decided to use that as an excuse to migrate our project to ESM. However, I hit a bug using dd-trace with ESM. Over a year later, that bug hasn't been resolved. I try to use ESM as much as possible for new projects, but it's not always simple to migrate existing projects to ESM.
Meanwhile I'm trying to adapt ESM only packages to CJS since Goja (JS runtime library for Go) doesn't have ESM support. [1]
And yes I know that Grafana has a fork called Sobek that has ESM support, but it is tailored for k6 and they don't have plans for making it easier to use. [2]
Did anyone make a large TypeScript codebase work with ESM + TS + node (together with IDE support and yarn pnp support?)
The thing that is annoying with ESM is that it requires to have extensions in imports, i.e. `import .. from '/foo.js'`.
This gets messy with TypeScript, where your files are named `foo.ts` but you need to import `foo.js`.
The previous "best practice" in TS world was to have extensionless JS imports, so this move would require a massive codemod to update all imports in an entire codebase.
For now, we've been using `ts-node` with swc under the hood, but without ESM. I tried `tsx`, but the compilation time of esbuild is way too slow, some of our node CLI tools written in TS take 15s to boot up, which is not acceptable (with `ts-node` it's 3-4s) (tbh, it's probably partly a fault of our CLI framework, which crawls all workspaces to discover all CLI tools, and as we have a lot of them, tsx has a lot of useless work to do).
While `rewriteRelativeImportExtensions` works for frontend applications, it has a significant limitation: it doesn't fix declaration files (.d.ts), which is problematic when developing libraries or public packages.
I created a small tool to address ESM + TypeScript issues that the tsc doesn't handle:
https://github.com/2BAD/tsfix
Also there are eslint rules older than erasableSyntaxOnly that can also be useful in doing a "rip-the-bandaid-off-refactor" using all the lint warnings/warnings-as-errors to add extensions everywhere in the case where your brownfield also needs to be a version or two behind on Typescript.
In my experience, the biggest obstacle to ESM in production systems is every observability/tracing tool relies on require-in-the-middle or in some other form CJS require().
The way this cutover is being handled is reminiscent of Python 2->3. To this day, I still run into compatibility issues between things that only work with ESM and things that only work with CJS. It's frustrating, to say the least.
> Although a significant portion of packages still use CJS, the trend clearly shows a good shift towards ESM.
Does it?
The chart being used in the opening argument is far from compelling. When I hold my phone sideways, it looks as though the ESM portion is plateauing or feebly (at best) increasing. Maybe by 2040 we’d see widespread ESM adoption?
Jokes aside, I prefer ESM (and would prefer if everyone else preferred it), but leading with adoption rates is weak sauce.
All these new bundling libraries (vite/rollup) always claim to just work, chefs kiss, 100 emote. And then you actually try to use them for basic use cases and you find yourself googling or knee deep in the documentation. If at all possible I try to avoid bundling libs altogether. That's the future I want. No more vite or rollup or webpack.
My experience with Vue/Vite is in fact chef's kiss 100 emote.
I would not want to set up a new project from scratch myself, but with the templates it works great, as long as you don't have to do anything outside of what they want you to do.
Uh? That’s completely alien to my experience with Vite. Setting up a Vite/React/Typescript project with Tailwind/Tanstack Query/Tanstack Router is an absolute breeze, you just follow the docs. Adding ESLint/Prettier is also just following the docs.
What problems did you encounter specifically? Did you report them?
As a side note it was so depressing reading the release notes of the latest VS Code. It was all just more and more AI slop related nonsense. You could feel the enshitification happening in real time. I don’t think there was a single new feature in there I was even remotely excited about.
I've been ESM only for the last 2 years and it has been amazing. Now to be fair, I am doing it on new projects so that probably simplifies things, but it is very freeing and performant.
I am surprised they still use outdated syntax when there is a standard for modules. Also, it is much better when you specify full path to the file and do not have to remember the resolving rules.
Also, last time I tried to use ESM in a browser with Vue and unpkg without builders and web server, it didn't work (probably because one cannot import modules from a local folder). I wish support for ESM and localhost was improved also. I don't want to use a webserver or a bundler because it is easier without them so I switched to legacy scripts without modules.
Pretty sure this works and it is indeed possible to use ES modules without builders and a webserver, I recently rewrote a Node app this way (with Claude's help even because I'm a ES noob).
No idea about Vue and unpkg though, there could be an issue with that.
edit: eh you mean like "import 'file://module.js'", but I don't understand why would you need that when you can use "import './module.js'"
> file: URLs are supported by many non-browser runtimes such as Node, since scripts there already have file: URLs, but they are not supported by browsers due to security reasons.
If you open HTML file directly in a browser, it cannot import ES modules from a filesystem. It can import legacy scripts, but they, as I understand, cannot import anything at all.
You don't need file:// module URLs when you say open a index.html from your local drive through file://C:/index.html but then in that index.html you have a 'script type="module" src="src/index.js"' and from there you go through usual relative paths without protocol.
This works in Firefox, I have one project open like that right now. Chrome blocks that because of their CORS policy, but that's another issue, can be mitigated by some commandline options (--allow-file-access-from-files --cors-exempt-headers "*")
Firefox has the same behavior (I just tested using Firefox 136 on macOS). The error message says "CORS request not HTTP"[0]. You might have disabled `security.fileuri.strict_origin_policy` in about:config?
To achieve an ESM-only future, we can't have features that only work in non-ESM. Browsers don't support synchronous blocking loading and execution of ESM scripts.
Any kind of migration breaks code. Every single line of practical code hit by a breaking change should be considered a bug. Before the security theater takes the stage, let me clarify by saying that I don't mean we should not have breaking changes at all, but that only by explicitly treating them as serious bugs, can we even hope to set up a process to minimize them. Of course, the minimization objective will have multiple other variables -- security being one of them.
Therefore, when it is possible to not break practical code, it should not be done. This is the "linux philosophy", if you will. Among JS runtimes, Bun is a great example. In bun today, you can, in the same file (!!), with zero code changes, 100% transparently:
1. require() a module regardless of whether it is cjs or esm [1]
2. synchronously `import` a module regardless of whether it is cjs or esm.
3. do either of this with typescript or javascript
Some of these required changes in JavascriptCore, which were implemented - it's good to see a lack of cribbing about "it needs to be supported in upstream" and a focus on practical end-user usage.
Any runtime/kernel that doesn't put this level of effort into not breaking code is just not serious. While Node is at least moving in the right direction, supporting require(esm) with some restrictions, Deno is completely hopeless - CJS code is completely unusable on Deno. The reasoning is crazy too - how does it matter if ESM is the "standard", if millions of lines of practical, real world code is using CJS? It does not matter that popular libraries move to ESM. Even a single internal tool that's locked into CJS for some reason means that a project cannot move away from it easily. Then like someone mentioned below, many "plugin architectures" and "command architectures" are pretty much locked in to require(). The whole Deno project screams "ideology > pragmatism". Hopefully, just like they came to their senses w.r.t node compat [2], they implement CJS interop as well.
[1] You cannot require() an ESM module that uses top level await
[2] In contrast, bun's node compat story is very good, they run the Node.js test suite on every commit to Bun, and pass a huge majority of the test cases. Track the progress here: https://bun.sh/docs/runtime/nodejs-apis
There is even some effort put into V8 APIs(in a JSC runtime!!). This helps with using Bun with the usual debugger in the chrome browser/VSCode, modules that use `node:v8`, etc,. Read about it here: https://bun.sh/blog/how-bun-supports-v8-apis-without-using-v...
We’ve gotten this discourse for decades. I remember it being for PHP, Java, JavaScript … in fact anything used in production.
It just happens that you get developers for what the market is offering and that the quality you’ve got depends more on experience than anything else. Oh and of course, juniors just learn what can make them land a job. But they are still junior and you still have to manage them.
They key thing is that require(esm) shipped in node 22, and is being back ported to node 20.
Since Node 18 maintenance ends in less than a month, this means all Node versions will have good esm support, including for consuming esm-only libraries (which until recently did not work!)
This is noted somewhat in the article, but basically is the whole story to me. Its now possible to stop doing CJS libraries entirely. And with that, I don't see why we would do CJS at all.
Annoyingly you have to use an experimental flag. That just adds too much friction.
Latest Node version added Node options as config feature. I wish that was ported to every version of Node.
Otherwise you have to set NODE_OPTIONS which can often be overwritten by some scripts in the execution chain.
require(esm) is no longer behind a flag in v22.12.0+ and v20.19.0+.
Funnily my conclusion was, on the contrary, that it's now possible to ignore ESM entirely when using/targeting nodejs ;)
(sadly at work, at this point we already have done the useless work of migrateming to ESM for no good reason other than having libs that were ESM only)
ESM has heaps of benefits: being able to do static analysis, or limiting the exposed modules in a package, for example.
Also not having to debug two separate shipped codebases. “The CommonJS version does X” is an annoying github issue to fix.
Tree-shaking also.
Static analysis and tree shaking have been done with CJS for about a decade.
https://gist.github.com/joepie91/bca2fda868c1e8b2c2caf76af7d...
The main argument in that post doesn't even hold; using ESM, you can always using dynamic imports:
Besides, static analysis and tree shaking completely break down if modules impose side effects from being required, which is one of the main gripes of Python as well. ESM completely alleviates that.That forces dynamic imports (and is horrible ergonomics as discussed in the post). CJS requires can be statically analyzed to figure out whether a require can be static, as is done e.g. in all bundlers.
ESM import runs side effects as well.
We have had top-level await for a while now. I don’t know what’s so horrible about this:
…if you’re so inclined to do things exactly as you did in the past. I’m pretty sure bundlers will even transform that into an ordinary module import.The cases where you actually need dynamic imports are few and far between. Are we actually talking about engine limitations here, or is it just a few snowflakes that insist on creating loggers like this?
What is so particularly pretty about that is beyond me.> ESM import runs side effects as well.
At runtime, yes. Not during static analysis.
In a limited way due to side effects from the require function.
Reading that rant, the author did not seem to take much time to understand the rationale behind ES modules: “ yet for some completely unclear reason, ESM proponents decided to remove that property” is just pure ignorance.
> In a limited way due to side effects from the require function.
In a limited way that covers something like 99.9% of CJS usage. Bundling is based on static analysis.
If by side effects you mean running code, not just declaring exports, ES6 import does these side effects too.
Thinking that CJS can't be used for static analysis or tree-shaking is the widest spread pure ignorance.
I'm doing server code (which I guess is the main use case for nodejs), I just COPY the project folder in the docker and ship that. No need for bundling, shaking and other annoyance.
Tree-shaking also happens in GC heap space (both in the server and in the browser): V8 and SpiderMonkey and JavaScriptCore will all eventually treeshake unused code out of memory entirely. The ESM format was designed to allow that. Modules actually only reference each other through weak references and things like import * build proxy objects of weak references designed for tree-shaking.
Depending on how your application is structured, an ESM version of a server-only Node application may still benefit from the performance optimizations of the server being able to do dead code elimination at runtime.
You'd likely still benefit from using TypeScript and transpiling that to plain JavaScript (preventing a lot of subtle bugs), and tree shaking to minimize your Docker image size (yielding faster deployments).
We do use TS already. And yeah sure, we could save a second or two on our deployment, but I'm not sure that's worth the mess
In case you ever get to that point; have a look at Unbuild (https://github.com/unjs/unbuild). It's what Vite uses for their own builds, and really painless to set up.
Haha I'm probably getting too old or something but I really cannot see what this tool does exactly and why I'd need it. I have been working on what is now a fairly large typescript/nodejs monorepo (over 1M LoC) for the past ~13 years, and we can simply build it all with tsc -b, and COPY it in the docker image as I said, it's nice and simple and works great.
There are still maintained and supported older versions of node.js by the likes of Debian, Ubuntu, Enterprise Linuxes. They backport security fixes and such during a longer extended window but are unlikely to port ESM-require support. It may not be relevant to you and obv nobody's obliging you to also support those users, just saying it's not that binary.
If you are pinning to unsupported node you should not expect new npm packages to work, ESM or not.
However given my NPM experiences in the past, I would not be surprised that someone updated to ESM in a revision bump.
Is that really "support those users"?
If they need to use a new library version, they can install their own version of node instead of relying on the OS supplied one.
All the OS version is doing is supplying the convenience of not having to install it.
Or have I misunderstood how those versions of node are used?
Sometimes the entire reason why they are still on that older LTS dist is because they still need (e.g.) node 12 for some reason.
That really becomes a "not your problem" as a library author.
If a user wants to use an old version of node, they'll have to use an old version of the library that still supports cjs.
Huh, oddly no mention of native browser support in the form of <script type=“module”> and importmaps! To me, that felt like the last platform to offer ESM support.
I also feel like browser support “feels” more official than support from bundlers.
Just weird to leave out a mention of that.
It's certainly nice, and makes possible Vite's very quick reloads of developer code, but even Vite still makes (ESM) bundles for performance reasons, both for production (with everything, using Rollup) and for dev (with just the external deps, using ESBuild).
My understanding is that HTTP 2 pipelining was supposed to make requesting lots of ESM modules from the same site fast enough that bundling could be consigned to history, but for various reasons it didn't work as intended and it was ultimately removed from most browsers.
HTTP protocol changes were never, ever, going to make bundling unnecessary. No one actually involved in the things said it would, or thought it would. It was less-deeply-involved people misunderstanding things that led to that popular impression.
HTTP/2’s better multiplexing helps a little, but you’ve still completely got the waterfall problem: so you have at an absolute minimum of overhead the dependency graph depth times the round-trip time—frequently multiple seconds.
HTTP/2 Server Push could have improved it in some regards, as it can in theory break out of the waterfall problem, but in practice it would have required much more complex servers, and was missing important pieces so that it was completely useless anyway (a way for the client to tell the server what resources it has cached), and they eventually just removed it all round rather than inventing and implementing the missing pieces, with which it still would have been a good deal less efficient to execute than bundling.
Minifying and bundling is just better, no matter what.
HTTP/2 “pipelining” (multiplexing) is still there and works as intended; but bundling is still much more efficient.
This article delves into just that: https://csswizardry.com/2023/10/the-three-c-concatenate-comp... The first pair of waterfall graphics illustrates the problem clearly.
(What you vaguely remember as not working as intended is probably Server Push).
> My understanding is that HTTP 2 pipelining was supposed to make requesting lots of ESM modules from the same site fast enough that bundling could be consigned to history, but for various reasons it didn't work as intended and it was ultimately removed from most browsers.
HTTP/2+ Pipelining works pretty well. (As others mentioned, it was HTTP/2 Server Push that didn't quite survive in the wild, which could have helped additional scenarios.)
I've been personally moving towards Vite's dev approach for Production (just external deps and big libraries with esbuild) and little to no bundling in dev (only external deps that don't run out-of-the-box as ESM with an importmap). A handful of small "local" files and couple big shared libraries works very well in Production in my experience.
Oh yeah I assumed so. I just thought it was weird to not mention it in an article about ESM becoming the standard JS modules format. Even if it’s not the most performant use of ES modules, I still think it’s certainly the most official “signal” of support to have it in all major browsers.
Sadly ESM don't work with local files (file:// protocol).
That would be expected for a browser environment though! I don’t want any module my browser imports to silently import something off the filesystem without telling me.
while i agree with that, i don't quite understand why this is an issue if the main index.html file is from the local filesystem too. the filesystem should only be off limits if the main file is loaded from a website.
i suppose the problem is mixing local and remote sources. i don't know if blocking remote sources from loading local ones is enough or even easy to do. if not then that would explain why local needs to be blocked.
Most systems come with Python today, so starting an HTTP server for local testing is often a one-liner like `python -m http.server`. Anyone already working with Node has access to one-liners like `npx http-server`. (Deno and Bun also have one-liners.)
It is not that easy. You need to open console and navigate to the project directory first. While with ordinary HTML files all you need is to start typing its name in address bar (or simply never close the tab).
This isn't ESM's fault, this is the CORS security model that applies to all JS in most browsers. Some of the browser's "grandfather in" some looser restrictions for file:// origin JS files from file:// origin HTML files, with Firefox being the least restrictive I'm aware of, but you can trigger CORS blocks in all of them for JS files of many types, not just ESM. ESM just wound up on the other side of the expiration of "grandfathered in" loose restrictions for CORS checks in most browsers.
Sure, there's a learning curve to running even a one-liner localhost HTTP server, but when I was learning HTML the first time there were all sorts of strange learning curves (many of which aren't even relevant anymore).
Sure, it makes it harder to distribute things like Twine apps, but there are known workarounds (bundle all the scripts into the HTML file) that Twine already does. (I'd love to see a new well-supported "HTML app container" format/standard to download/distribute HTML apps safely. It seems unfortunate that PWAs went so deep into Service Worker mania and the simple ideas like I should be able to ZIP a folder of HTML and JS files, rename it to something like myapp.pwa and it "just work" kind of got lost in several shuffles. Sure, Service Worker-based file auto-updating is nice when it works, but it is so complicated and sometimes I just want a dumb ZIP-like file users can double-click.)
On a side note, I recently experimented with native TS support in Node.js and it felt like magic: no need for extra flags; debugging and watch just work; types are simple to re-use between browser and server. Erasable syntax seems like the way forward, can't wait for it to land in browsers.
Same here - it's really quite something. The future is looking good!
That being said, there are currently still some hurdles. Necessity for explicit file-extensions in the imports is definitely the big offender (it's invalid typescript syntax without the allowImportingTsExtensions-flag).
The trend is definitely clear though, most devs want ESM, most devs want types; it's just a matter of time until the ecosystem adapts. I suppose for types to finally land in the browser, TC39 will have to undergo the "progress is one funeral at a time"-principle, which will probably take another while.
> can't wait for it to land in browsers
Presumably for download size and backward compatibility everyone will still serve JS to browsers.
But it'd be nice not to /have to/. Specially when working on new projects with no build step.
Typescript Types don't add that much to download size and generally compress very well.
It is really nice. I'm glad the Node and Typescript teams finally starting making pragmatic decisions to actually be useful.
Now typescript just needs some better defaults from tsc --init to match :)
> The Toolings are Ready
> Modern Tools
> With the rise of Vite as a popular modern frontend build tool, many meta-frameworks like Nuxt, SvelteKit, Astro, SolidStart, Remix, Storybook, Redwood, and many others are all built on top of Vite nowadays, that treating ESM as a first-class citizen.
> As a complement, we have also testing library Vitest, which was designed for ESM from the day one with powerful module mocking capability and efficient fine-grain caching support.
> CLI tools like tsx and jiti offer a seamless experience for running TypeScript and ESM code without requiring additional configuration. This simplifies the development process and reduces the overhead associated with setting up a project to use ESM.
> Other tools, for example, ESLint, in the recent v9.0, introduced a new flat config system that enables native ESM support with eslint.config.mjs, even in CJS projects.
I think the author and I must have very different definitions for the word "ready"
How would you define “ready” here?
Definitely "stable", but also "simple" and "straightforward"
So you need a bundler, a linter and a test runner. How’s that any different from the experience in other languages where you need a compiler, a linter and a test runner?
s/a/one
I was working on a legacy CSJ project, and I tried to upgrade an OIDC library, but the newer version would only work with ESM. I decided to use that as an excuse to migrate our project to ESM. However, I hit a bug using dd-trace with ESM. Over a year later, that bug hasn't been resolved. I try to use ESM as much as possible for new projects, but it's not always simple to migrate existing projects to ESM.
Meanwhile I'm trying to adapt ESM only packages to CJS since Goja (JS runtime library for Go) doesn't have ESM support. [1]
And yes I know that Grafana has a fork called Sobek that has ESM support, but it is tailored for k6 and they don't have plans for making it easier to use. [2]
[1] https://github.com/dop251/goja/issues/348
[2] https://github.com/grafana/sobek/issues/49
Did anyone make a large TypeScript codebase work with ESM + TS + node (together with IDE support and yarn pnp support?)
The thing that is annoying with ESM is that it requires to have extensions in imports, i.e. `import .. from '/foo.js'`.
This gets messy with TypeScript, where your files are named `foo.ts` but you need to import `foo.js`.
The previous "best practice" in TS world was to have extensionless JS imports, so this move would require a massive codemod to update all imports in an entire codebase.
For now, we've been using `ts-node` with swc under the hood, but without ESM. I tried `tsx`, but the compilation time of esbuild is way too slow, some of our node CLI tools written in TS take 15s to boot up, which is not acceptable (with `ts-node` it's 3-4s) (tbh, it's probably partly a fault of our CLI framework, which crawls all workspaces to discover all CLI tools, and as we have a lot of them, tsx has a lot of useless work to do).
Hey! Not sure how modern your codebase is, but you can consider the following tsconfig settings:
- rewriteRelativeImportExtensions: this will allow you to write `import foo from './foo.ts'` and have tsc transform it to `import foo from './foo.js'`
- erasableSyntaxOnly: this will error on non "erasable" syntax, that is, TypeScript code that has a runtime output (e.g. enums)
With these two settings enabled, you'd be able to run TypeScript code directly with Node: `node src/index.ts`, and cut boot up time substantially
To add, those are the recommended tsconfig.json settings in Node's docs on native TS stripping. Here are the rest: https://nodejs.org/api/typescript.html#type-stripping
While `rewriteRelativeImportExtensions` works for frontend applications, it has a significant limitation: it doesn't fix declaration files (.d.ts), which is problematic when developing libraries or public packages.
I created a small tool to address ESM + TypeScript issues that the tsc doesn't handle: https://github.com/2BAD/tsfix
Also there are eslint rules older than erasableSyntaxOnly that can also be useful in doing a "rip-the-bandaid-off-refactor" using all the lint warnings/warnings-as-errors to add extensions everywhere in the case where your brownfield also needs to be a version or two behind on Typescript.
How about for aliases?
import foo from '@Schemas/foo.ts' won't work since it is not a 'RelativeImport'. Is there a fix for this use case?
I use a custom loader to deal with this:
https://github.com/theogravity/fastify-starter-turbo-monorep...
Usage:
Search for loader.js refs.
https://github.com/theogravity/fastify-starter-turbo-monorep...
It’s pretty easy to find/replace everywhere that needs it.
In my experience the reason people don’t is that it offends their aesthetics.
Which I understand. But personally I don’t program for aesthetics.
In my experience, the biggest obstacle to ESM in production systems is every observability/tracing tool relies on require-in-the-middle or in some other form CJS require().
Even "require at the start" (mandatory for transparent polyfills) seems incompatible with the way ESM wants to do things.
The way this cutover is being handled is reminiscent of Python 2->3. To this day, I still run into compatibility issues between things that only work with ESM and things that only work with CJS. It's frustrating, to say the least.
> Although a significant portion of packages still use CJS, the trend clearly shows a good shift towards ESM.
Does it?
The chart being used in the opening argument is far from compelling. When I hold my phone sideways, it looks as though the ESM portion is plateauing or feebly (at best) increasing. Maybe by 2040 we’d see widespread ESM adoption?
Jokes aside, I prefer ESM (and would prefer if everyone else preferred it), but leading with adoption rates is weak sauce.
All these new bundling libraries (vite/rollup) always claim to just work, chefs kiss, 100 emote. And then you actually try to use them for basic use cases and you find yourself googling or knee deep in the documentation. If at all possible I try to avoid bundling libs altogether. That's the future I want. No more vite or rollup or webpack.
My experience with Vue/Vite is in fact chef's kiss 100 emote.
I would not want to set up a new project from scratch myself, but with the templates it works great, as long as you don't have to do anything outside of what they want you to do.
Vite is probably the only one that just works? If you follow the first 3mins of Getting Started it works great.
Uh? That’s completely alien to my experience with Vite. Setting up a Vite/React/Typescript project with Tailwind/Tanstack Query/Tanstack Router is an absolute breeze, you just follow the docs. Adding ESLint/Prettier is also just following the docs.
What problems did you encounter specifically? Did you report them?
VS Code still doesn't support ESM in extensions, you have to transpile to CJS.
https://github.com/microsoft/vscode/issues/130367
As a side note it was so depressing reading the release notes of the latest VS Code. It was all just more and more AI slop related nonsense. You could feel the enshitification happening in real time. I don’t think there was a single new feature in there I was even remotely excited about.
I've been ESM only for the last 2 years and it has been amazing. Now to be fair, I am doing it on new projects so that probably simplifies things, but it is very freeing and performant.
The main problem with es modules is mocking in tests. How do people work around that issue?
Define an interface, mock the implementation. How people outside of JS/TS have been doing it for years.
You either use Vitest for new projects or you use jest.unstable_mockModule for old Jest tests
You don't use modules as a replacement for DI?
I am surprised they still use outdated syntax when there is a standard for modules. Also, it is much better when you specify full path to the file and do not have to remember the resolving rules.
Also, last time I tried to use ESM in a browser with Vue and unpkg without builders and web server, it didn't work (probably because one cannot import modules from a local folder). I wish support for ESM and localhost was improved also. I don't want to use a webserver or a bundler because it is easier without them so I switched to legacy scripts without modules.
> one cannot import modules from a local folder
Pretty sure this works and it is indeed possible to use ES modules without builders and a webserver, I recently rewrote a Node app this way (with Claude's help even because I'm a ES noob). No idea about Vue and unpkg though, there could be an issue with that.
edit: eh you mean like "import 'file://module.js'", but I don't understand why would you need that when you can use "import './module.js'"
Doesn't work in a browser [1]:
> file: URLs are supported by many non-browser runtimes such as Node, since scripts there already have file: URLs, but they are not supported by browsers due to security reasons.
If you open HTML file directly in a browser, it cannot import ES modules from a filesystem. It can import legacy scripts, but they, as I understand, cannot import anything at all.
[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
You don't need file:// module URLs when you say open a index.html from your local drive through file://C:/index.html but then in that index.html you have a 'script type="module" src="src/index.js"' and from there you go through usual relative paths without protocol.
This works in Firefox, I have one project open like that right now. Chrome blocks that because of their CORS policy, but that's another issue, can be mitigated by some commandline options (--allow-file-access-from-files --cors-exempt-headers "*")
> Chrome blocks that because of their CORS policy
Firefox has the same behavior (I just tested using Firefox 136 on macOS). The error message says "CORS request not HTTP"[0]. You might have disabled `security.fileuri.strict_origin_policy` in about:config?
[0]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/COR...
Yeah, happened long ago so I didn't remember it. Still for dev env I think it's a reasonable tradeoff.
While you can't use it without a webserver, today that webserver can be a one-liner like `npx http-server` or `python -m http.server` at least.
To achieve an ESM-only future, we can't have features that only work in non-ESM. Browsers don't support synchronous blocking loading and execution of ESM scripts.
ECMAScript modules
Any kind of migration breaks code. Every single line of practical code hit by a breaking change should be considered a bug. Before the security theater takes the stage, let me clarify by saying that I don't mean we should not have breaking changes at all, but that only by explicitly treating them as serious bugs, can we even hope to set up a process to minimize them. Of course, the minimization objective will have multiple other variables -- security being one of them.
Therefore, when it is possible to not break practical code, it should not be done. This is the "linux philosophy", if you will. Among JS runtimes, Bun is a great example. In bun today, you can, in the same file (!!), with zero code changes, 100% transparently:
1. require() a module regardless of whether it is cjs or esm [1]
2. synchronously `import` a module regardless of whether it is cjs or esm.
3. do either of this with typescript or javascript
Some of these required changes in JavascriptCore, which were implemented - it's good to see a lack of cribbing about "it needs to be supported in upstream" and a focus on practical end-user usage.
Any runtime/kernel that doesn't put this level of effort into not breaking code is just not serious. While Node is at least moving in the right direction, supporting require(esm) with some restrictions, Deno is completely hopeless - CJS code is completely unusable on Deno. The reasoning is crazy too - how does it matter if ESM is the "standard", if millions of lines of practical, real world code is using CJS? It does not matter that popular libraries move to ESM. Even a single internal tool that's locked into CJS for some reason means that a project cannot move away from it easily. Then like someone mentioned below, many "plugin architectures" and "command architectures" are pretty much locked in to require(). The whole Deno project screams "ideology > pragmatism". Hopefully, just like they came to their senses w.r.t node compat [2], they implement CJS interop as well.
[1] You cannot require() an ESM module that uses top level await
[2] In contrast, bun's node compat story is very good, they run the Node.js test suite on every commit to Bun, and pass a huge majority of the test cases. Track the progress here: https://bun.sh/docs/runtime/nodejs-apis
There is even some effort put into V8 APIs(in a JSC runtime!!). This helps with using Bun with the usual debugger in the chrome browser/VSCode, modules that use `node:v8`, etc,. Read about it here: https://bun.sh/blog/how-bun-supports-v8-apis-without-using-v...
There is also compat the other way - there are the beginnings of node/browser polyfills for Bun-only APIs: https://github.com/oven-sh/bun/tree/main/packages/bun-polyfi...
[flagged]
Calling typescript devs "low quality" while also citing python as "high quality" actually made me laugh out loud.
Thank you for the chuckle.
We’ve gotten this discourse for decades. I remember it being for PHP, Java, JavaScript … in fact anything used in production.
It just happens that you get developers for what the market is offering and that the quality you’ve got depends more on experience than anything else. Oh and of course, juniors just learn what can make them land a job. But they are still junior and you still have to manage them.
See this is why I only hire high-quality assembly only developers. Not low-quality C, Python, or Rust developers!
Sounds like the circlejerk mindset I want my competitors to have I just ship code.