Configuring Internal Package exports in a pnpm Workspace — A Practical Guide to CJS/ESM Dual Packages and publint
When building a monorepo, you'll inevitably hit a wall at some point. You carefully craft an internal package, import it in your app, and suddenly types aren't resolving at all, Jest throws a module-not-found error, or — most baffling of all — your singleton splits into two separate instances. I remember spending days on this myself when first building a shared utility package in a pnpm Workspace environment, all because I didn't properly understand the exports field in package.json.
This post distills the core cause of that pain — the dual package problem that arises when CJS and ESM coexist in one package — and walks through how to systematically diagnose and fix it using conditional exports fields and publint. If you're already running a pnpm monorepo, or just getting started with one, this should be immediately applicable in practice.
CJS (CommonJS) is Node.js's original module system, using require() and module.exports. ESM (ES Modules) is the official JavaScript standard (ES2015), using import/export syntax. Node.js 12+ introduced official ESM support, bringing both systems into coexistence within the same ecosystem — and this transitional period is precisely the backdrop for the problems we're tackling today.
Core Concepts
The exports Field — The Evolution of main/module
If you look at older packages, you'll often see both main and module fields. main was the CJS entry point; module was an unofficial field that bundlers used when reading ESM. The problem was that neither was part of the official Node.js spec, and each tool interpreted them differently.
Starting with Node.js 12+, the exports field was officially introduced as their replacement. It supports Conditional Exports, which let you serve different files depending on the environment condition, and as of 2025 it has become the de facto standard.
{
"name": "@myorg/utils",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}Once you declare exports, it takes precedence over main/module. Any internal paths not explicitly exposed (e.g., ./src/internals) become inaccessible from outside, which has the added benefit of making your package's API boundary explicit.
Condition key reference
import: When loaded via an ESMimportstatementrequire: When loaded via CJSrequire()node: When running in a Node.js runtime environmentbrowser: When running in a browser environment (commonly used for SSR and universal libraries)types: When TypeScript resolves typesdevelopment/production: Custom conditions (require explicit activation in bundler / tsconfig)default: Fallback when none of the above conditions match
Condition keys are matched top-to-bottom in declaration order. If default appears first, all remaining conditions are ignored — so default must always be last.
The CJS/ESM Dual Package Hazard
Honestly, this problem is really hard to diagnose the first time you encounter it. The symptoms look like completely unrelated bugs — things like "why isn't this value initialized?"
Here's what happens: when a package that has both CJS and ESM entry points is loaded both ways within the same app, Node.js's CJS loader and ESM loader each create a separate module instance. You end up with two copies of the same package in memory.
App
├── require('@myorg/utils') via CJS → creates Instance A
└── import '@myorg/utils' via ESM → creates Instance B (different from A!)This is most damaging for code that holds module-level state — caches, singletons, event emitters. State registered on Instance A simply doesn't exist on Instance B.
Key insight: Modules that hold state (singletons, global caches, event buses) should be considered for conversion to ESM-only rather than dual packaging. Stateless pure utility functions are comparatively safe to ship as dual packages.
pnpm Workspace and the workspace:* Protocol
pnpm Workspace declares its managed scope in pnpm-workspace.yaml, and internal packages reference each other using the workspace:* protocol.
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'// apps/web/package.json
{
"dependencies": {
"@myorg/shared-utils": "workspace:*"
}
}In this structure, a misconfigured exports field in an internal package will cause type resolution failures or module load errors even during local development.
With that foundation in place, let's walk through how to put these concepts together in real code — from build to validation.
Practical Implementation
Step 1: Build CJS and ESM Simultaneously with tsup
I initially tried a Rollup + tsc --declaration combination, but the configuration got too complicated and I eventually switched to tsup. It's esbuild-based so builds are extremely fast, and the configuration needed for dual packages comes down to just a few lines.
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
clean: true,
splitting: false,
sourcemap: true,
outExtension: ({ format }) => ({
js: format === 'cjs' ? '.cjs' : '.mjs',
}),
});splitting: false is safer to disable because code splitting in CJS builds gets converted to dynamic require() calls, which can cause unexpected issues. The outExtension setting is also easy to overlook — without it, the CJS output is emitted as .js instead of .cjs. When exports in package.json then points to a .cjs file, it won't be found, so being explicit here is important.
The build produces the following files:
| File | Purpose |
|---|---|
dist/index.cjs |
CJS bundle |
dist/index.mjs |
ESM bundle |
dist/index.d.cts |
Type declarations for CJS |
dist/index.d.mts |
Type declarations for ESM |
Why is
.d.mtsneeded? InmoduleResolution: node16orbundlermode, TypeScript looks for types for.mjsfiles in.d.mtsfiles. Sharing a single.d.tscan cause resolution context mismatches and errors, so keeping separate declaration files for CJS and ESM is the recommended approach as of 2025.
Step 2: Separating Dev and Publish Environments with the publishConfig Pattern
This is a pattern where you reference TypeScript source directly during development without rebuilding each time, and the build artifacts are automatically swapped in when you run pnpm publish. It's extremely common in practice, though the configuration can look lengthy at first glance.
{
"name": "@myorg/shared-utils",
"version": "1.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"development": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"publishConfig": {
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
}| Field | Development | After publish |
|---|---|---|
main |
./src/index.ts |
./dist/index.cjs |
types |
./src/index.ts |
./dist/index.d.ts |
exports["."] (ESM import, dev) |
./src/index.ts (development condition matches first) |
./dist/index.mjs |
exports["."] (CJS require, dev) |
./src/index.ts (development condition matches first) |
./dist/index.cjs |
exports["."]:development |
./src/index.ts (direct source reference) |
(removed) |
There's a key point here. Because the development condition is declared before import, in a development environment both ESM import and CJS require() will match development first. During development, you're always referencing ./src/index.ts directly; the import/require conditions only become relevant after publishing.
For the development condition to activate, the consuming side's tsconfig.json needs this:
{
"compilerOptions": {
"moduleResolution": "bundler",
"customConditions": ["development"]
}
}There's also a reason to prefer moduleResolution: bundler. The node16 and nodeNext modes require import paths to explicitly include extensions like .js or .mjs. In a monorepo where you're frequently referencing internal packages, this gets tedious quickly. bundler mode resolves paths without extensions and aligns well with how bundlers like Vite and webpack actually operate.
That said, it's worth knowing upfront that the development condition is handled differently across tools. Vite reads customConditions from tsconfig.json without additional configuration, but webpack requires you to explicitly add "development" to resolve.conditionNames. If the development condition isn't working in your environment, check the tool-specific configuration first.
Step 3: Validating exports Configuration with publint
Sometimes you think everything is configured correctly, only to hit errors after actually publishing. publint simulates how npm will interpret your package after publishing — before you publish — so you can catch problems early. I used to think "this looks good enough" and move on, but running publint revealed hidden errors I hadn't spotted.
# Validate the current package
npx publint
# Validate a specific node_modules package
npx publint ./node_modules/some-lib
# Validate all dependencies at once
npx publint depsThe most useful integration in practice is adding it to prepublishOnly. The validation runs automatically after the build, preventing accidents where a misconfigured package gets published.
{
"scripts": {
"build": "tsup",
"prepublishOnly": "pnpm build && npx publint"
}
}The most common errors publint catches:
| Error type | Description |
|---|---|
FILE_DOES_NOT_EXIST |
A file declared in exports doesn't actually exist |
FILE_FORMAT_INVALID |
A .mjs file is written in CJS format (or vice versa) |
EXPORTS_TYPES_INVALID |
The types sub-condition appears after default inside an import or require block |
EXPORTS_DEFAULT_SHOULD_BE_LAST |
The default condition is not positioned last |
EXPORTS_TYPES_INVALID is an easy one to misread at first. It's not about the top-level exports ordering — it means that within an import or require block, types must come before default. For example, writing the import block as { "default": "...", "types": "..." } triggers this error. The correct order is { "types": "...", "default": "..." }.
Step 4: Validating Type Declarations with are-the-types-wrong
While publint validates file structure and format, are-the-types-wrong (attw) validates whether TypeScript type declarations actually align with how the runtime module resolves. Using both tools together significantly raises publish quality.
npx @arethetypeswrong/cli --pack .It's great to integrate both tools in CI:
{
"scripts": {
"check:exports": "npx publint && npx @arethetypeswrong/cli --pack ."
}
}Pros and Cons
Advantages
| Item | Details |
|---|---|
| Universal compatibility | Supports both Jest and older Node.js apps (CJS) as well as Vite and modern Node.js (ESM) environments |
| Tree shaking | Bundlers can eliminate unused code via the ESM entry point |
| Clear API boundary | Only paths declared in exports are accessible externally — reduces accidental exposure of internal implementation |
| Development convenience | publishConfig + development condition enables a fast development cycle where you reference source directly without building |
| Automated validation | Integrating publint and attw into CI prevents misconfigured packages from being published |
Disadvantages and Caveats
One case I ran into personally: there were many places throughout the app that directly imported from subpaths like @myorg/utils/internals. The moment I added exports, all those undeclared paths were blocked and the build broke everywhere at once. When introducing exports to an existing package, always audit which paths are being referenced externally first.
| Item | Details | Mitigation |
|---|---|---|
| Dual Package Hazard | The same package gets instantiated separately by the CJS and ESM loaders, splitting singleton state | Consider switching stateful modules to ESM-only |
| Duplicated type files | Must maintain separate .d.cts and .d.mts files |
Auto-generate with tsup's dts: true option |
| Configuration complexity | exports, publishConfig, tsconfig, and build tool settings are all intertwined, making debugging difficult |
Validate step-by-step with publint + attw |
| Tool-specific interpretation differences | webpack, Rollup, Node.js, and TypeScript can each interpret exports conditions differently |
Recommend standardizing on moduleResolution: bundler |
| Subpath blocking | After declaring exports, unlisted internal paths become inaccessible externally — adding it to an existing package may be a breaking change |
Audit all externally referenced paths before migrating |
Dual Package Hazard — A situation where the same package is independently initialized by the CJS loader and the ESM loader, resulting in two separate module instances coexisting in memory. This can lead to unpredictable bugs in singletons, global state, event emitters, and similar constructs.
The Most Common Mistakes in Practice
- Declaring the
typescondition at the top level instead of insideimport/requireblocks: Writing"types": "./dist/index.d.ts"at the top level ofexportscauses TypeScript to use the same types regardless of CJS/ESM context. It's recommended to specifytypeswithin eachimportandrequireblock separately. - Adding
dist/to.gitignorewhileexportspoints todist/: A fresh clone or a CI environment running before the build step will fail because the files don't exist yet. Ensure the build step is guaranteed via aprepareorprepublishOnlyscript. - Declaring
defaultbeforeimport: Conditional exports match in declaration order. Ifdefaultcomes first,import/requireconditions are never reached.defaultmust always be last.
Closing Thoughts
Correctly configuring the exports field and validating it with publint is the most reliable way to ensure internal package quality in a pnpm Workspace environment in 2025.
tsup handles the build output automatically and publint catches what's wrong, so once you have the structure in place, publint will catch most regressions going forward. That said, it's worth knowing upfront that management overhead scales linearly with the number of exports paths. If you have a monorepo running today, here's how to check it right now:
- Run
npx publinton your current internal packages. You'll immediately see what's wrong with yourexportsconfiguration. For existing packages, expect more warnings than you might anticipate. - Add
format: ['cjs', 'esm'], dts: true, outExtensionto yourtsup.config.tsand build. It will generate.mjs,.cjs,.d.mts, and.d.ctsfiles in one pass. Then update theexportsfield inpackage.jsonto match the examples above. - Add
"prepublishOnly": "pnpm build && npx publint"to yourpackage.jsonscripts. From that point on, validation runs automatically on every publish, preventing misconfigured settings from reaching npm.
Next post: A practical guide to package versioning and changelog automation in a monorepo with pnpm Workspace + Changesets
References
- pnpm official docs - Workspaces
- pnpm official docs - package.json
- publint official site
- publint rules documentation
- Getting started with publint
- How to solve package validation pain with Publint | LogRocket
- Ship ESM & CJS in one Package | Anthony Fu
- Publishing dual ESM+CJS packages | Mayank
- Dual Publishing ESM and CJS Modules with tsup and Are the Types Wrong? | John Reilly
- Guide to the package.json exports field | Hiroki Osame
- TypeScript in 2025 with ESM and CJS npm publishing is still a mess | Liran Tal
- Tutorial: publishing ESM-based npm packages with TypeScript | 2ality
- Live types in a TypeScript monorepo | Colin McDonnell
- Dual Packages in Node.js with Conditional Exports | Alexander O'Mara
- Node.js official docs - Publishing a package
- TypeScript official docs - ESM/CJS Interoperability
- Complete Monorepo Guide: pnpm + Workspace + Changesets (2025)