Skip to Content
Course content

223: Dual CJS/ESM Package Publishing with Types

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I released a small utility library for schema validation. I was feeling modern, so I shipped it as a "pure ESM" package. I thought I was doing the community a favor by forcing the move toward the future. Then, my inbox exploded. Dozens of developers were hitting the dreaded ERR_REQUIRE_ESM error because their legacy Node.js projects—which still relied on require()—couldn't touch my code. I spent my entire weekend rewriting my build pipeline just to support CommonJS, realizing that in the real world, "modern" has to be "compatible."

The Exports Map: Navigating the Dual-Module Divide

If you want your library to be usable by everyone, you can't just rely on the old "main" field in your package.json. While "main" tells Node where the entry point is, it doesn't distinguish between a CJS require and an ESM import. To solve this, we use the "exports" map. This is essentially a routing table for your package.

Imagine we are building a library called valis-ts. We'll compile our TypeScript source into two separate directories: dist/cjs and dist/esm. Your package.json should look like this:

{
  "name": "valis-ts",
  "version": "1.0.0",
  "type": "module", 
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs"
    }
  }
}

Notice that I set "type": "module" at the top level. This tells Node that .js files are ESM by default. To make the CommonJS version work, I've named the file index.cjs. That explicit extension overrides the package-level "type" and tells Node, "Regardless of the settings, treat this specific file as CommonJS."

Solving the Type Declaration Dilemma

Types are where things get tricky. If you just provide one "types" field at the root of your package.json, TypeScript might struggle to map the types to the correct module format depending on how the consumer's moduleResolution is configured. The cleanest way to handle this in modern TypeScript is to include the types directly within the exports map.

You want the TypeScript compiler to find the declarations whether the user is in a CJS or ESM environment. I usually recommend a structure like this:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs"
    }
  }
}

Wait, why is types only listed once? In most cases, your type definitions are agnostic to the module system—a string is a string whether it's imported via require or import. However, the types key must come first in the object. Node.js and TypeScript resolve these keys in order; if you put import first, the type checker might skip the types key entirely.

Avoiding the Dual Package Hazard

Here is a warning I wish someone had given me sooner: the "Dual Package Hazard." This happens when a user's dependency tree accidentally imports both the CJS and ESM versions of your library. Because Node treats them as two different files, it creates two separate instances of your module.

If your library is stateless (just a collection of pure functions), you're fine. But if you have a singleton, a global cache, or internal state, you'll end up with two versions of that state running in the same process. It's a nightmare to debug. If you absolutely must have state, the safest bet is to have your ESM wrapper actually require the CJS version internally, ensuring only one instance ever exists. But for 90% of TS libraries, simply keeping your logic pure and stateless is the path of least resistance.




📋 Practical Task

Implementing a Dual-Build Configuration for "valis-ts"

You have been handed a basic TypeScript project with a single file src/index.ts that exports a function validateEmail(email: string): boolean. Your goal is to configure the project for dual publishing.

Requirements:

  • Modify the package.json to include an exports map that supports both import (pointing to ./dist/esm/index.js) and require (pointing to ./dist/cjs/index.cjs).
  • Ensure that TypeScript declarations are correctly mapped within the exports map so that consumers get full intellisense.
  • Set the package "type" to "module" to align with modern standards.
  • Explain in a brief comment why the "types" key must be placed at the top of the exports object.

Starter Code:

// src/index.ts
export function validateEmail(email: string): boolean {
  return email.includes('@');
}

// package.json (incomplete)
{
  "name": "valis-ts",
  "version": "1.0.0",
  "main": "dist/index.js"
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.