-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
223: Dual CJS/ESM Package Publishing with Types
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.jsonto include anexportsmap that supports bothimport(pointing to./dist/esm/index.js) andrequire(pointing to./dist/cjs/index.cjs). - Ensure that TypeScript declarations are correctly mapped within the
exportsmap 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 theexportsobject.
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"
}
There are no comments for now.