The CommonJS vs ES Modules "War" is Taxing for Us Regular Folks Out Here - One Way To Interop
5 min read

The CommonJS vs ES Modules "War" is Taxing for Us Regular Folks Out Here - One Way To Interop

There are a few paths forward to interoperability (in most cases I know of at least) that are relatively painless. The ecosystem hasn't landed on the right conventions to support ESM in a reasonable way, especially (unfortunately for a lot of us) when using a TypeScript project.
The CommonJS vs ES Modules "War" is Taxing for Us Regular Folks Out Here - One Way To Interop

You can get through this article without being an expert on either, but if you want details, I recommend checking out the commonjs vs es modules here.  If you already know a little, the next bit shouldn't be too much to digest!

There are a few paths forward to interoperability (in most cases I know of at least) that are relatively painless.  The ecosystem hasn't landed on the right conventions to support ESM in a reasonable way, especially (unfortunately for a lot of us) when using a TypeScript project - so unfortunately we have to piece a few things together.  It additionally doesn't help that some  package owners out there seem to be trying to "force" people to adopt ESM by only releasing ESM versions of their packages.  (Not trying to throw shade here - I get it - I just don't love it given churn it causes for lack of the node.js loader api and many modules taking different approaches to compatibility - or none at all.) In my opinion, it simply doesn't make sense for most larger codebases to mass migrate to ESM at the time of this writing due to high complexity and very low benefit, and I mean very low benefit.  Instead, I recommend one of two approaches, each of which I'll describe below.

Is it "Bad" to Stay on CommonJS Since The Standard is Becoming ES Modules?

First off, you may be wondering why stay commonjs with TypeScript (for now)?

  • I don't want to add .js to all my local imports
  • I use some test runner / mocking solution that doesn't support ES Modules
  • I use open telemetry runtime instrumentation that needs "require"
  • I don't care about top level await
  • I don't particularly need the proposed "security" benefits of ESM
  • I don't care about better tree shaking (which bundlers handling CommonJS appear to do just fine anyways) because I'm on a server. (And if you're in a serverless environment - I say maybe, but I still think bundling is an anti-pattern on the server).

If you can get away with it, then I would, at least for now. However, you may have that one dependency that decided to go full ESM and no longer support CJS, and now you're wondering "how can I upgrade myself without going all in on ESM?"

Option 1 - Use A Dynamic Import Wrapper to Import the ES Module in Question Into CommonJS

Let's use a boilerplate nest.js app here as a reference that you start with right after running their code generator for scaffolding a new backend.

npm i -g @nestjs/cli
nest new es-modules-interop-test
cd es-modules-interop-test
npm run start:dev

Everything works fine right? Not for long! Let's add one of those pesky "ES Modules Only" packages and figure out a path forward.  At the time of this writing,  got is one of these. (Again disclaimer about throwing shade - this is not an attempt to say one person in this argument is wrong, my intention here is to demonstrate how to deal with the pain this "ecosystem split" has caused in a sane way.)

npm install got

Rewrite your boilerplate app.service.ts as:

import { Injectable } from '@nestjs/common';
import got from 'got';

@Injectable()
export class AppService {
  async getHello(): Promise<string> {
    return got
      .get<{ origin: string }>('https://httpbin.org/ip', {
        resolveBodyOnly: true,
      })
      .then((body) => body.origin);
  }
}

And you app.controller.ts as:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): Promise<string> {
    return this.appService.getHello();
  }
}

As soon as you save, your app will fail to restart (assuming you followed the instructions to run in "dev mode"):

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /Users/$USER/Desktop/es-modules-interop-test/node_modules/got/dist/source/index.js
require() of ES modules is not supported.
require() of /Users/$USER/Desktop/es-modules-interop-test/node_modules/got/dist/source/index.js from /Users/$USER/Desktop/es-modules-interop-test/dist/app.service.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename index.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /Users/$USER/Desktop/es-modules-interop-test/node_modules/got/package.json.

    at new NodeError (internal/errors.js:322:7)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1102:13)
    at Module.load (internal/modules/cjs/loader.js:950:32)
    at Function.Module._load (internal/modules/cjs/loader.js:790:12)
    at Module.require (internal/modules/cjs/loader.js:974:19)
    at require (internal/modules/cjs/helpers.js:101:18)
    at Object.<anonymous> (/Users/$user/Desktop/es-modules-interop-test/src/app.service.ts:2:1)
    at Module._compile (internal/modules/cjs/loader.js:1085:14)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
    at Module.load (internal/modules/cjs/loader.js:950:32)

In the case of a more heavy handed framework like nest.js, you have the ability to leverage an async factory, which is where you can leverage your dynamic import of the got package - which in fact - is allowed in CommonJS 🎉 (which means if this is the only package you need, you can upgrade without changing the rest of your repository).

Try updating the following:

// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [
    {
      provide: AppService,
      async useFactory() {
        return new AppService((await import('got')).default);
      },
    },
  ],
})
export class AppModule {}

// app.service.ts
import { Injectable } from '@nestjs/common';
import type { Got } from 'got';

@Injectable()
export class AppService {
  constructor(private got: Got) {}

  async getHello(): Promise<string> {
    return this.got
      .get<{ origin: string }>('https://httpbin.org/ip', {
        resolveBodyOnly: true,
      })
      .then((body) => body.origin);
  }
}

I know this isn't a nest.js tutorial, but essentially what's happening in the above is:

  1. We added got as a private constructor variable in the AppService to allow "injection" of it into the service instead of a top level import
  2. We're adding a provider factory to allow an "async" setup to pass our dynamically imported got module into our service for use.

Based on the CJS / ESM documentation this should work right!?

Nope - you'll notice again we have the same error as above! For what it's worth, if we were in regular Javascript land this would work by itself, but wouldn't you know it, TypeScript has it's own problems with this one - it's actually rewriting that dynamic import as a require statement 😢.  It's even sadder to realize that there's no way around this without hiding your dynamic import from the TypeScript compiler with a hackier method.  Again, if this is the only import you need a quick fix to get your codebase moving along, update app.module.ts one more time like so:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

const dynamicImport = async (packageName: string) =>
  new Function(`return import('${packageName}')`)();

@Module({
  imports: [],
  controllers: [AppController],
  providers: [
    {
      provide: AppService,
      async useFactory() {
        return new AppService((await dynamicImport('got')).default);
      },
    },
  ],
})
export class AppModule {}

After that, you'll notice that everything will magically start working again.  You can shake your head in dissapproval all day, but this in my opinion is the least disruptive change you can possibly make to your code base and apply support of packages that decided to go "full ESM".

In summary, while it feels like a hack to dynamic import this way, it's an easy path forward that gets you what you need without a lot of extra fluff or a new build system on top of your existing TypeScript build system.  You could even go so far as to wrap all your modules that need this technique into a single function return in a separate file, allowing you to expose access to all your dynamic import packages in one place, allowing easy future cleanup when the community at large has better first class support for such things.