This lesson preview is part of the Bundling and Automation in Monorepos course and can be unlocked immediately with a \newline Pro subscription or a single-time purchase. Already have access to this course? Log in here.

This video is available to students only
Unlock This Course

Get unlimited access to Bundling and Automation in Monorepos, plus 90+ \newline books, guides and courses with the \newline Pro subscription.

Thumbnail for the \newline course Bundling and Automation in Monorepos
  • [00:00 - 00:32] Before we dive into how we're going to handle packages in our monorepo, I want to go into how packages are handled when published to npm, and specifically the way that packages need to handle their exports, what they expose to other packages for importing. In the previous lesson, we talked about how ESM and CJS are handled within a package, but once we cross the package boundary, things get much more complicated.

    [00:33 - 01:02] The package "exports" field supersedes the historic "main" and "module" fields that were in use before Node 14. They allow us to define multiple entry points into a package, or to define export conditions where we supply different code for different environments. Additionally, all files that are not explicitly exported are private to the package.

    [01:03 - 01:11] No other package can import those. And just quickly you can think about ESM being the "import" syntax, while CJS being the "require()" syntax.

    [01:12 - 01:33] ESM allows for top level await, but as I mentioned previously, we should not be using that in any code that's exported. The specific reason for that is code that does not use top level await can be required from CJS without using asynchronous import() function.

    [01:34 - 01:46] Instead, it can just directly be required. And import statements are statically parsed, while require() statements are a normal function that can show up at any point in the file.

    [01:47 - 01:58] I'm going to go into a complex example of package.json. First, we have the old style for Node that does not support exports.

    [01:59 - 02:45] This is, I think, before Node 17 here we would supply a "module" entry point with the top level module key CJS entry point with the top level "main" key, and we can supply "types" that apply to the CJS types not to the module types—that's an important distinction, again, as a top level package.json setting. The new style is to specify things in an "exports" map where the order of things is actually important. First, it's always a good idea to export your package.json. As we said, with exports, anything that's not defined in exports is considered private and cannot be read by other tools.

    [02:46 - 02:57] Then come conditional exports. If we have only a single entry point and we don't supply TypeScript types, then something like this can be adequate.

    [02:58 - 03:09] The module sink condition was added in Node 22.10. It's a condition specific to Node.js that behaves similarly to the module condition that's specific to bundlers.

    [03:10 - 03:27] What it does is it will force require calls to resolve to ESM rather than to CJS code. The reason to do that is both because that code is going to load faster, and because this prevents the duo package hazard.

    [03:28 - 03:39] We're going to discuss that later. For now, "module-sink" is the modern Node specific export condition. Then we have "module". This is a bundler specific export condition.

    [03:40 - 03:54] It behaves similarly to "module-sync" where if you have a require code it's going to use the ESM export even though a CJS export might be available as well. After that we have the "import" condition.

    [03:55 - 04:04] This is the classic ESM condition that can resolve to code that is not require-safe. That means it can contain asynchronous module code.

    [04:05 - 04:22] And finally, you should always have a default condition which usually would be a CJS export. So this is a way for us to conditionally export different files depending on where they're being imported. Are they being imported by modern NodeJS?

    [04:23 - 04:31] Are they being imported by old NodeJS? Are they being imported by a bundler or are they being imported by very old NodeJS?

    [04:32 - 04:44] If we want to include TypeScript in this, things get more complicated. So you can nest conditions.

    [04:45 - 05:12] We can say that. We can say that the module-sync condition provides types and the default export for module-sync. So when TypeScript uses a resolution that selects this condition it's going to use this types file. Then we might have a specific browser export.

    [05:13 - 05:30] If we have a browser export we need to again nest into our import and default exports. Just to make sure that we provide the correct builds based on usage, then we have our old import syntax, and finally the default syntax.

    [05:31 - 05:42] So this is something that I've seen used in actual packages. And the exact way that you would structure this depends on what your package is doing.

    [05:43 - 05:49] It might not have a browser condition. It might instead have let's say a react-server condition.

    [05:50 - 06:01] Or it might have a runtime condition like Deno. It depends on what your module is doing, but as you can see, this gets very complicated very quickly and it's extremely easy to get wrong.

    [06:02 - 06:11] Finally, we can specify additional entry points. So here we're saying that just importing the package will give you these imports.

    [06:12 - 06:30] But we can also say importing the package/calculator is going to give you different imports. This is very often a good strategy to utilize in your own code. And we're going to see a way to make this a bit simpler for internal packages.

    [06:31 - 06:45] But you still need to go to the whole song and dance of the different conditions defined types for each condition. Notice that the types for module are different from the types for CJS.

    [06:46 - 07:08] They are incompatible, unfortunately because of quirks of the CJS and the module system. So you do need to have separate builds of your types for CJS and ESM when you're publishing a dual module package and a couple of other things to remember. Entry points must always start with a dot.

    [07:09 - 07:14] So you start from the root of your package slash calculator. That's your entry point. And you can do wildcard exports.

    [07:15 - 07:29] But don't do it. They're not worthwhile over providing explicit exports. In Node 24 this is the list of conditions that's currently supported.

    [07:30 - 07:53] So "node" and "node-addons" is specifically for code that makes use of Node's internal packages. Think about stuff like the "node:path" package or the "node:fs" package. "module-sync" as we said, is for modern Node that allows you to either import or require the same code directly.

    [07:54 - 08:13] "import" is the old ESM style. "require" is specifically for CJS entry, but most of the time you would omit that and just have default be your CJS entry. The order of the conditions that you provide in your exports matters.

    [08:14 - 08:31] Node and bundlers will walk from top to bottom to the conditions that you have defined and will resolve on the first that matches the current environment. And you should always finish with "default" because that's guaranteed to always work.

    [08:32 - 08:39] Now there are other conditions recognized by most bundlers. We talked about "module" before.

    [08:40 - 08:50] This is a way to say even though we have a require call, still use the ESM code because that's going to tree-shake better. "browser" is for browser specific code.

    [08:51 - 09:19] "react-server" and "react-native" are for those specific environments. "development" and "production" are just named conditions that are widely used by most bundlers. You can also have any custom name that you want and put special handling in your bundler, if that was something that you needed. "workers" are specifically for web workers where you don't have a DOM context.

    [09:20 - 10:11] "electron" is for Electron apps and can use specific internal modules of Electron, and "deno" or "bun" are for packages that specifically rely on imports from those environments. I want to again point out it's very important that you have the correct order of conditions. Usually custom conditions go first, then custom environments, then module-sync, which is the current modern option, then module for bundlers, then you would put deno, react-native, react-server, browser, import as the ESM fallback and default. This is usually... this is usually the order of conditions that you want to maintain.

    [10:12 - 10:17] And you don't need to have all of them. You just pick the ones that are relevant to your project.

    [10:18 - 10:33] Any change to exports must always be treated as a SemVer Major breaking change. Because trust me, this will break some weird environment for someone in an unexpected way.

    [10:34 - 11:10] Another thing worth calling... worth calling out is that when you're compiling packages with ESM and CJS this can lead to very weird behavior where the same package is imported by different dependencies and they use different resolution modes, and you end up with one dependency importing the CJS version and one importing the ESM version. They would not be the same. "instanceof" checks between them are going to give you false, or any internal package state is not going to be the same.

    [11:11 - 11:30] A common workaround for this is that you only build CJS, and then you have ESM be a wrapper that imports CJS. But this is both complicated to do in build time and at publish time, and it makes your tree shaking significantly worse.

    [11:31 - 11:45] The future and where the ecosystem is going towards is just to publish ESM only packages. This gets rid of, of about half of the conditions that you need to support in your exports and makes the build process easier.

    [11:46 - 11:57] It means you don't have to do separate TypeScript builds for ESM and CJS. There's a lot of complexity that's going to go away as the ecosystem migrates to that.

    [11:58 - 12:20] And with require() ESM being backported to Node 20 as of March of 2025, this is now supported in all LTS releases of Node. So it's an easy change unless you specifically want to support environments before Node 20 with your package.

    [12:21 - 12:32] And obviously bundlers already can handle ESM only code. Additionally, TypeScript types, as I said, multiple times, are not the same between CJS and ESM.

    [12:33 - 12:42] Sometimes you can work around with this weird module.exports thing. But it will depend on your specific TypeScript configuration.

    [12:43 - 13:09] We're going to go more into that, into our next lesson. But in any case, if you are actually publishing a package, you should always verify that it behaves as expected. Using this: Are the types wrong tool. As an example, using the @reduxjs/toolkit package, we can see that it resolves a package.json export.

    [13:10 - 13:25] It resolves a main export, and then three entrypoints, a "/react", a "/query", and the "/query-react" exports. And it can correctly resolve all types and environments for each of those.

    [13:26 - 13:40] And that's about everything for this lesson. Now that we know what it takes to publish a package on npm, we're going to go with a much lighter version on creating internal packages. That is going to be easier for us to maintain.