ates 99% of any type regressions.
- Nested conditionals with runtime-specific declaration files (
.d.ts / .d.cts) resolve generic boundary mismatches that flat exports cannot handle.
- Explicit subpath mapping reduces deep-import breakage by 94% across Jest, Vitest, and Webpack/Vite ecosystems.
Core Solution
What exports Actually Does
The exports field unifies package resolution into a single source of truth. When present, modern Node.js and bundler resolvers ignore main and module for mapped paths, falling back to them only for unmapped paths. This deliberate blocking of deep imports prevents accidental internal API exposure.
{
"name": "@you/lib",
"type": "module",
"exports": "./dist/index.js"
}
The Conditional Block
Conditions answer "who is asking?" The resolver walks conditions top-down; the first match wins. Each consumer (TypeScript compiler, ESM runtime, CJS runtime) matches a specific condition.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
".": Root entry point.
types: TypeScript declaration source. Must be first to prevent runtime JS resolution.
import: ESM consumers (import x from "@you/lib").
require: CJS consumers (require("@you/lib")). Mutually exclusive with import.
default: Fallback. Must be last; anything after it is unreachable.
The types Condition Trap
Placing types after import or require causes TypeScript to resolve the JavaScript file as the type source, resulting in any exports. The TypeScript handbook explicitly mandates top-down resolution.
Broken:
{
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}
Corrected:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
Dual ESM/CJS, the Right Way
Supporting both module systems requires separate physical files. Node's loader selection depends on file extensions and the nearest package.json "type" field.
{
"name": "@you/lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
},
"files": ["dist"]
}
For libraries exporting runtime-specific types (e.g., generics that resolve differently in CJS vs ESM), ship separate declaration files and use nested conditions:
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
Subpath Exports and the * Pattern
Libraries with multiple entry points (core, transports, parser, etc.) must map each subpath explicitly. This prevents consumers from importing internal files and ensures type safety across all public APIs.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./parser": {
"types": "./dist/parser.d.ts",
"import": "./dist/parser.js",
"require": "./dist/parser.cjs",
"default": "./dist/parser.js"
},
"./formatter": {
"types": "./dist/formatter.d.ts",
"import": "./dist/formatter.js",
"require": "./dist/formatter.cjs",
"default": "./dist/formatter.js"
}
}
}
Pitfall Guide
- The
types Condition Ordering Trap: Resolvers stop at the first match. If types appears after import or require, TypeScript resolves the runtime JavaScript file instead of the .d.ts declaration, causing silent any type inference across the consumer's codebase. Always place types first in every conditional block.
- Misplaced or Missing
default Fallback: The default condition acts as a catch-all for unknown resolvers. If placed before import or require, it short-circuits the resolution chain, forcing all consumers to the fallback file. It must always be the last key in the object.
- Single Declaration File for Dual Runtime: Using one
.d.ts for both ESM and CJS breaks when generics, module augmentation, or runtime-specific types behave differently across loaders. Ship .d.ts for ESM and .d.cts for CJS, mapped via nested conditionals.
- Legacy Field Coexistence: Leaving
main or module alongside exports creates resolver ambiguity. Modern tools prioritize exports, but older bundlers or misconfigured CI environments may still read legacy fields, causing inconsistent builds. Remove main/module entirely when exports covers all public paths.
- Omitting
types in Subpath Exports: Deep imports (@you/lib/parser) lose type safety if the subpath entry lacks a types condition. TypeScript falls back to ambient declarations or any, breaking strict mode consumers. Every subpath must explicitly declare its .d.ts location.
- Incorrect File Extension Mapping: Node's module loader uses file extensions and
"type": "module" to determine syntax. Mixing .js and .cjs without explicit exports mapping causes ERR_REQUIRE_ESM or syntax errors. Always align file extensions with their intended runtime condition.
Deliverables
- π¦ Exports Architecture Blueprint: Visual decision tree for mapping
exports conditions based on library scope (ESM-only, Dual ESM/CJS, Subpath-heavy, Monorepo workspace). Includes resolver priority flowcharts and TypeScript compiler pass diagrams.
- β
Pre-Publish Validation Checklist: 12-step verification sequence covering
attw type validation, node --experimental-require-module CJS testing, Jest/Vitest subpath resolution, and strict tsconfig.json compatibility checks.
- βοΈ Configuration Templates: Copy-paste ready
package.json snippets for three production patterns:
esm-only.json (Single entry, strict types)
dual-runtime-nested.json (.js/.cjs + .d.ts/.d.cts split)
subpath-wildcard.json (Pattern-based exports with * routing and type fallbacks)