Enabling ESM with TS, without the headaches

This post was written from an optimistic standpoint, enabling ESM and TS on a new project, making it a straight forward thing to do.
If you’re migrating an existing codebase, see my recent article on that topic right here, as that’s a much harder thing to do.

Are you wanting to get on the latest trend and replace CommonJS (CJS) with EcmaScript Modules (ESM)? Maybe you’re forced to upgrade because the libraries you’re using are. Maybe you’ve even tried, with the aid of ChatGPT but still came up empty-handed? Or maybe you’re just attempting this migration because you’re upgrading node and decided that you’d update your tsconfig.json too, to reflect more recent standards?.

In any case, migrating from CJS to ESM can be a headache and it includes a bunch of variables.

But don’t worry, I’m here to help you complete such a migrate but without all the headaches.

The quick overview of the process is something like:

  1. Configure project to handle ESM
  2. Change imports across application
  3. Enable partial migrations
  4. Test compilation
  5. Handle Errors

But before we dive into it, let’s consider why you might want to migrate to ESM.

The biggest and two most widespread reasons are:

  • Top level await
  • Tree shaking when using module bundlers

First things first: Configuring project to handle ESM

The first thing we’ll want to do, is to ensure that our project is setup to handle ESM instead of CJS. This takes a little fiddling but is quick to setup.

The first thing we’ll want to do is to add two new configuration options to our compilerOptions in tsconfig.json as follows:

...
"module": "esnext",
"moduleResolution": "node16",
...

In the same file I would also recommend adding the following: "esModuleInterop": true but it’s more of a convenience thing.

It allows us to import non-ESM packages as a default import, which is great.

Do note, that it’s highly recommended that you turn this off if you’re writing library code.

Next up, we want to head over to our package.json and simply add "type": "module" and that’s it as far as configuration goes!

Now there could still be some caveats in your project which we’ll come back to in a later section (see “handle errors” below), but for now let’s move on to step 2.

The bulk of the work: Updating all your imports

For this next task I’m afraid you’ll be spending a great deal of time tinkering with granular details, as an unfortunate consequence of migrating to ESM is that we’re now required to append the .js extension.

This part can be a bit tricky if you’re working with Typescript, especially if you’re a newer developer as it can be hard to figure out what extension to add, especially if you’re seeing errors!

It’s nice to remember that the extension will always be .js.

Even if you’re working with a .ts or .tsx file, the compiled output will be .js or .jsx, and that’s what you’re importing. So, for example, if you have a file named myModule.ts, you’ll import it like this:

import myModule from './myModule.js';

Not like this:

import myModule from './myModule.ts';

This is because when Typescript compiles your code, it turns your .ts files into .js files.

So, you’re actually importing the compiled Javascript file, not the original Typescript file.

Making it all convenient: Enabling partial migrations

Sometimes, it’s not feasible to migrate your entire codebase from CJS to ESM all at once. In such cases, you can do a partial migration.

There are several reasons why it might not be feasible to migrate your entire codebase from CJS to ESM all at once. For instance, your project might be large and complex, making it difficult to update everything at the same time.

Or, you might be working in a team where different parts of the codebase are owned by different people, and it’s not practical to coordinate a full migration all at once.

Consider a scenario where you’re working on a large-scale web application that has been developed over several years. The application has multiple modules, each with its own set of dependencies and complexities.

Migrating all these modules at once could potentially disrupt the application’s functionality and lead to significant downtime, which is not desirable in a production environment. In such cases, a partial migration, where you gradually transition individual modules from CJS to ESM, can be a more practical approach.

And thankfully Node.js does allow you to use both ESM and CJS in the same project and get started with a partial migration.

You can specify the type of each file individually by using the .cjs and .mjs extensions. Files with the .cjs extension will be treated as CommonJS, while files with the .mjs extension will be treated as ESM.

Challenges of a partial migration

While you may already know I’m a big advocate for partial migrations, we can’t deny they have their own set of challenges.

Because partial migrations do come with their own set of challenges.

If you’re using both ESM and CJS in the same project, you’ll need to be mindful of how the two module systems interact.

For instance, ESM and CJS have different semantics for this, and ESM doesn’t support require().

You’ll also need to ensure that your tooling, such as your bundler and linter, can handle both module systems.

Moreover, partial migrations can lead to a temporary increase in complexity, as developers need to understand and work with two module systems instead of one.

It’s also important to have a clear plan for completing the migration and removing CJS from your codebase, to avoid ending up in a situation where you’re indefinitely supporting both module systems.

Testing Compilation

After you’ve updated your imports, the next step is to test your code to ensure everything is working as expected. This involves running your test suite and manually testing your application.

If you don’t have a test suite, now might be a good time to consider setting one up.

Your test suite should cover all the critical functionality of your application.

Automated testing is a crucial part of modern software development and can save you a lot of time and headaches in the long run.

In addition to running your test suite, you should also manually test your application. This involves going through your application and testing all the key functionality.

You should test anything that interacts with the modules you’ve updated, to ensure there are no unexpected side effects.

Remember, the goal of testing is to catch and fix errors before your users do. So, be thorough and test everything you can think of.

But what if you encounter errors during testing? Don’t worry, that’s a normal part of the migration process. In the next section, we’ll discuss how to handle and debug common errors that can occur when migrating from CJS to ESM. Stay tuned..

Handling Errors

During the migration process, you’re likely to encounter a few errors. Here are some common ones and how to fix them:

  1. Uncaught SyntaxError: Cannot use import statement outside a module: This error occurs when you’re trying to use an import statement in a script that hasn’t been defined as a module. To fix this, make sure you’ve added "type": "module" to your package.json file.
  2. Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: This error occurs when you’re trying to require an ESM module. To fix this, replace the require statement with an import statement.
  3. Error [ERR_MODULE_NOT_FOUND]: Cannot find module: This error occurs when Node.js can’t find the module you’re trying to import. To fix this, make sure the path to the module is correct and that the module has been installed.

In conclusion, migrating from CJS to ESM can be a bit of a headache, but it’s definitely worth it. Not only does ESM offer benefits like top-level await and tree shaking, but it’s also the future of JavaScript. So why not get ahead of the curve and start migrating your projects to ESM today?

In summary

While ESM is the future of JavaScript, it’s still relatively new and not all libraries and tools support it yet.

Therefore, it’s important to check the compatibility of your dependencies before starting the migration process.

Also, while the migration process can be complex and time-consuming, it’s a good opportunity to refactor your code and improve your project’s overall structure and quality.