Using ES Modules in Node.js: A Practical Guide (Part 1)
gils-blog.tayar.org
gils-blog.tayar.org
Problems that I encountered:
To get `__dirname`, you have to do an awkward workaround using path and url [3].
For npm modules that implement a path e.g. `@lib/method`, the import of this currently isn't allowed in node without a flag.
For ESM incompatible modules, a createRequire [2] function is available to get the old `require` syntax.
For import statements, the cache cannot be deleted yet [4].
Overall, I'm please to see that nodejs will soon be ESM compatible. All changes that have been made look reasonable and useful to me.
In fact, I now often get the pleasurable "wow that makes sense" feeling when using node. It has has great docs, nice new features like worker_threads and it's productive for me. I'm a fan!
1: https://github.com/TimDaub/kloi
2: https://nodejs.org/api/module.html#module_module_createrequi...
3: https://stackoverflow.com/questions/46745014/alternative-for...
Node.js has the workaround with the "exports{}" map in the package.json file, but there is no equivalent on the Browser-side, so you constantly have to work with dozens of "../../to/index.mjs" files as wrappers that do nothing more than a lot of "export * from ./some/other/file.mjs" inside them, which is a bit annoying.
As a side note (most people aren't aware of it): All Browsers have support for ESM modules, including Safari; and even WebKit2GTK. [1]
I think what you're looking for is `require.resolve` [1].
```mjs
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const path = require.resolve("package");
console.log(path);
>> ../path/to/mymodules/package/index.mjs
```
Personally, I wouldn't recommend to override the require.resolve or import.meta.resolve algorithm, as this is heavily experimental.
1 https://blog.logrocket.com/es-modules-in-browsers-with-impor...
FWIW, (and im sure you know this, just pointing it out for others) @lib/method is not a path - the @ at the beginning indicates the package is namespaced under an org. @lib/method is a full and proper regular package, whereas `lib/method` would be pointing to a path within the lib package.
1: With ES Modules, imports are asynchronous.
Synchronous imports are incredibly powerful. You can ensure that your application doesn't block on first requests, but instead on load. Initializing connection pools, setting up caches, etc.
You'll also find quite a few packages in NPM that explicitly depend on require over import just so that they can perform the bindings they have to before things get going (see https://www.npmjs.com/package/honeycomb-beeline as an example).
2: With ES Modules imports are commonly destructured.
I know it makes your code feel lighter to have const { add } = import("whatever"); - but as your files grow larger, and those imports start to become complicated bits for middleware or other features, you're just making it more difficult for future maintainers to figure out what those functions mean. A bit of context never hurt anybody, and for my money I'll be that this:
app.use(check);
is not nearly as useful as
app.use(Validator.check);
The example may be contrived - but 9/10 times I find this makes code better than the alternative.
All of these arguments go out the door for Frontend work - that's a totally different terrain. I think one of the big arguments is that developers want to use the exact same JavaScript on frontend and Node applications - but that's an argument unwilling to admit the fact that they're two very different tasks. :)
`import { check as ValidatorCheck } from "validator";`
This keeps the context at the expense of a slightly more verbose import.
Although the primary reason I use destructuring is to enable tree-shaking, and that's only really applicable for the frontend. I'm not sure why I'd destructure on the backend.
But modules still run in depth-first import order, so there is a perfectly fine way do work before other modules run by putting that init code in a separate module and importing it first.
Your second point applies the same to imports and require.
Thus your whole static import tree will be resolved before the first request comes in, assuming you start the server listening in the body of your main module. If you start it in the body of an imported module, and have other later imports that have start-up task side effects, then yeah you might have some requests come in before those later side effects happen, but why would you structure your code like that? The modules approach generally favors not having side effects in from importing modules, except the program's top level module of course.
Now dynamic imports are fully asynchronous, returning a promise. But even then you can structure your code to ensure they are loaded before your start accepting connections, or you can structure dynamic imports to be lazy and on-demand. This is not much different from CJS where you can also structure any dynamic imports to be either lazy or eager. The main difference is that with modules you need to deal with a promise while with CJS's require you don't.
That was super helpful - thanks for explaining!
I'm still lost as to what the advantage is other than a faster start-up time. What am I missing?
On the other hand, benefits of ESM on Node are currently conditional on whether you’re running the same code on the browser and want consistent behavior on the server. For server-only projects I honestly think you’re still better off using a build step that compiles to CommonJS. That may change as Deno grows in popularity and interoperability becomes more of a concern, or as other compelling ESM features are introduced (for instance native import maps).
Frankly - it does pretty much everything I want it to. I can write code in Typescript and have it feel like there's no build step at all.
I can use ESM everywhere - frontend, backend, buildscripts, random tasks, etc - and have no problems.
That said - It's not a legacy codebase, and it's only getting hobby level usage (currently serving a few requests a second), so there might be hurdles I'm just not hitting.
Once you mix commonjs and esm and try to import from one to another, it doesnt work anymore. Since major BE libs are commonjs you'll quickly face this situation. It's not related to ts-node but to ts' core. Besides, I find ts-node not that spectacular, it hides too much moving parts/it's too high level, it might be good for starters but nothing I'd use in production.
import { a } from "lib"
const { b } = a
Also it "sounds" like something one would say. from sys import <suggestions>
compared to node, where I have to write import {} from 'path'
then move the cursor in the braces and hit [Ctrl] + [Space] to trigger intellisense import {<suggestions>} from 'path'
which feels backwards to me. const moduleName = "foo"
import something from moduleName // will not work
const something = await import(moduleName) // will not work
I'm genuinely curious about how someone would do this with ES modules: // somewhere in a function or a class method
const plugins = pluginModules.map(require)
EDIT: I was wrong, the import() function does work with variables. const plugins = await Promise.all(pluginModules.map(p => import(p)))
[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe... import("module") // will work
import(p) // will not work
import(`${p}`) // will not work // test2.mjs
console.log("works!");
// test.mjs
const m = "./test2.mjs";
import(m)With ES6 modules, bundlers and transpilers are not mandatory anymore for a large part of modern JS development. This is a good thing, as they were a giant source of complexity.