npm 15 ๐ Structuring a Package for Publishing (main, module, types, exports)
A published package is consumed by many different resolvers: Node.js in CommonJS mode, Node.js in ESM mode, TypeScript with legacy module resolution, TypeScript with modern bundler or node16 resolution, and bundlers like Vite and Webpack. Each resolver looks at package.json differently. The four fields that determine whether a package works across all of them are main, module, types, and exports. Getting them right means every consumer finds the correct file. Getting them wrong means some consumers see types while others do not, some load the ESM build while others load CJS, and the failure modes are diagnosed through tsc --traceResolution output that is difficult to read even for package authors .
This chapter covers each field, the precedence rules between them, and the exports map that modern packages should use as the primary entry-point definition. It examines the condition ordering rules that determine whether TypeScript finds declarations, the dual-format pattern for packages that ship both ESM and CommonJS, and the validation tool that simulates every consumer scenario before publishing.
Key point: exports takes precedence over main and module in modern resolvers. Once exports is defined, unlisted subpaths become inaccessible. The types condition must be the first key inside each exports condition block, or TypeScript will read the JavaScript file as types and fail to find declarations .
Why these fields exist
The resolver fragmentation problem. Node.js historically used main to locate a package’s entry point. Bundlers later adopted module as a convention for the ESM build. TypeScript introduced types to locate declaration files. None of these fields was designed together, and none of them knows about subpath exports or conditional resolution. A package that only sets main and types works under legacy node module resolution and almost nowhere else. Modern consumers using moduleResolution: "bundler" or "node16" report “Cannot find module or its corresponding type declarations” .
The encapsulation problem. Without exports, every file in a published package is importable. A consumer can reach into my-package/lib/internal/util.js and depend on an implementation detail that the author never intended to be public. When the author refactors, the consumer breaks. The exports field exists to define the public API surface explicitly: only listed subpaths are accessible, and everything else throws ERR_PACKAGE_PATH_NOT_EXPORTED . This encapsulation is a feature, not a restriction.
The dual-format problem. A package that ships only ESM cannot be require()d by a CommonJS consumer. A package that ships only CJS cannot be imported by an ESM consumer without interop shims. The exports field solves this with conditional resolution: the import condition points to the ESM build, the require condition points to the CJS build, and both are listed in the same map . The trade-off is the “dual package hazard”โtwo module instances that do not share stateโbut for most libraries the hazard is manageable, and the alternative is excluding half the ecosystem.
The types resolution problem. TypeScript resolves declaration files through exports when moduleResolution is set to bundler, node16, or nodenext. If the types condition is not first in the condition block, TypeScript evaluates import or require first, resolves to a .js file, and reports that it cannot find declarations . The ordering is not a style preference; it is a resolution requirement.
a. The Legacy Fields: main, module, types
main is the oldest entry-point field. It defines the file loaded when the package is require()d or imported by a resolver that does not understand exports. It is supported by every version of Node.js and by tools like jsDelivr that read main for CDN publishing . For a CJS package, main points to the CJS entry file. For an ESM package, it points to the ESM entry.
{
"main": "./dist/index.cjs"
}
module is not an official Node.js field. It is a convention adopted by bundlersโRollup, Webpack, esbuild, and Viteโto locate the ESM build of a package . Node.js does not read module. The field exists because bundlers can tree-shake ESM more effectively than CJS, so they prefer the ESM build when it is available. A package that ships both formats sets main to the CJS file and module to the ESM file.
{
"main": "./dist/index.cjs",
"module": "./dist/index.js"
}
types points to the declaration file that TypeScript reads when the package is imported. For a package with a single entry point, types is a single path. For a package with subpath exports, types alone is insufficient because it does not map subpaths to their declarations. That mapping is handled by the types condition inside exports .
{
"types": "./dist/index.d.ts"
}
The limitation of these three fields is that they describe a single entry point. A package that exposes my-package and my-package/utils needs a mechanism that maps each subpath to its implementation and its declarations. That mechanism is exports.
b. The exports Map: Conditional Resolution and Encapsulation
exports is a map from import specifiers to files, with optional conditions that select different files based on the environment performing the resolution. The simplest form is a string that replaces main:
{
"exports": "./dist/index.js"
}
The conditional form selects different files for import and require. This is the pattern for dual-format packages:
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
The types condition must be the first key in the object. Resolvers walk the condition map top-down and stop at the first match. If import appears before types, TypeScript matches import, resolves to the .js file, and fails to find declarations . The correct order is always types first, then import, then require, then default.
Subpath exports extend the map to additional import specifiers. A package that exposes a utils module defines it explicitly:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
},
"./package.json": "./package.json"
}
}
The ./package.json entry is not decorative. ToolingโVite, Webpack, monorepo linkers, and type-version detectorsโreads package.json to discover package metadata. Without an explicit export for it, those tools error under moduleResolution: "bundler" . Exposing it is the standard practice for any package that defines exports.
Wildcard patterns handle groups of subpaths without listing each one:
{
"exports": {
"./features/*": "./dist/features/*.js"
}
}
A consumer importing my-package/features/auth resolves to ./dist/features/auth.js. The pattern exports every file in the features directory, which is appropriate when the directory is intentionally public. For directories that are not public, omit the wildcard and list only the specific subpaths that are part of the API .
c. Dual-Format Packages and Validation
A dual-format package ships both ESM and CJS builds, with exports mapping each condition to the appropriate file. The package’s type field determines how .js files are interpreted: "module" means .js is ESM, and CJS files use the .cjs extension. The reverse is also possibleโ"commonjs" as the type with .mjs for ESMโbut the ESM-first convention is more common in modern packages .
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"files": ["dist"],
"engines": { "node": ">=18" }
}
The main and module fields are retained for legacy resolvers that do not read exports. The exports map is the authoritative definition for modern resolvers. The files field is an allow-list of what ships in the tarball; dist is included, source files are not .
Validation before publishing is the step that catches resolver failures. The @arethetypeswrong/cli tool simulates every consumer scenarioโNode16 ESM, Node16 CJS, bundler resolution, legacy node resolutionโand reports which ones fail. A package that passes this check resolves correctly for every consumer type .
# Pack the package and check type resolution for every consumer scenario
npx @arethetypeswrong/cli --pack .
The npm pack --dry-run command complements this by showing exactly which files will ship. A dist directory that is missing from the tarball produces a package that installs but fails to import. Reviewing the dry-run output before every publish catches the packaging errors that resolver checks cannot .
Complete Example Session
// ============================================
// PART 1: MINIMAL LEGACY CONFIGURATION
// ============================================
// package.json โ works only under legacy node resolution
{
"name": "my-lib",
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}
// ============================================
// PART 2: ADD MODULE FOR BUNDLERS
// ============================================
// Bundlers prefer the ESM build for tree-shaking
{
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts"
}
// ============================================
// PART 3: ADD EXPORTS FOR MODERN RESOLVERS
// ============================================
// exports takes precedence over main and module
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
// ============================================
// PART 4: EXPOSE PACKAGE.JSON FOR TOOLING
// ============================================
// Vite, Webpack, and type-version detectors read it
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}
// ============================================
// PART 5: ADD A SUBPATH EXPORT
// ============================================
// Exposes my-lib/utils as a separate entry point
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
},
"./package.json": "./package.json"
}
}
// ============================================
// PART 6: WILDCARD SUBPATH EXPORT
// ============================================
// Exposes every file in dist/features/
{
"exports": {
"./features/*": {
"types": "./dist/features/*.d.ts",
"import": "./dist/features/*.js",
"require": "./dist/features/*.cjs"
}
}
}
// ============================================
// PART 7: CONTROL WHAT SHIPS
// ============================================
// files is an allow-list; .env and tests are excluded
{
"files": ["dist"],
"engines": { "node": ">=18" }
}
// ============================================
// PART 8: BUILD BEFORE PUBLISH
// ============================================
// prepublishOnly runs only on npm publish
{
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"prepublishOnly": "npm run build"
}
}
// ============================================
// PART 9: VALIDATE BEFORE PUBLISHING
// ============================================
// npm pack --dry-run shows the tarball contents
// @arethetypeswrong/cli checks resolver compatibility
// npx @arethetypeswrong/cli --pack .
// ============================================
// PART 10: COMPLETE CONFIGURATION
// ============================================
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
},
"./package.json": "./package.json"
},
"files": ["dist"],
"engines": { "node": ">=18" },
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"prepublishOnly": "npm run build"
}
}
The ten parts trace the progression from a legacy-only configuration to a complete dual-format package with subpath exports and validation. The critical step is Part 3, where exports is introduced: from that point on, the map defines the public API, and unlisted paths are inaccessible.
Quick Reference
Field Precedence
| Resolver | Reads | Precedence |
|---|---|---|
| Node.js (modern) | exports first | exports > main |
| Bundlers (Vite, Webpack) | exports, then module, then main | exports > module > main |
| TypeScript (bundler/node16) | exports with types condition | exports > types |
| TypeScript (legacy node) | types, then main | types > main |
exports Condition Order
| Order | Condition | Matches When |
|---|---|---|
| 1 | types | TypeScript resolving declarations |
| 2 | import | ESM import or import() |
| 3 | require | CommonJS require() |
| 4 | default | Fallback, always matches last |
Common File Layouts
| Format | Type Field | Entry Extension | Declaration |
|---|---|---|---|
| CJS only | commonjs | .js | .d.ts |
| ESM only | module | .js | .d.ts |
| Dual (ESM-first) | module | .js (ESM), .cjs (CJS) | .d.ts |
Best Practices
โ Do This:
// Put types first in every exports condition block
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } } // โ
// Expose package.json for tooling
"./package.json": "./package.json" // โ
// Use files as an allow-list
"files": ["dist"] // โ
// Keep main and module for legacy resolvers
"main": "./dist/index.cjs", "module": "./dist/index.js" // โ
# Validate resolver compatibility before publish
npx @arethetypeswrong/cli --pack . # โ
โ Don’t Do This:
// Put import before types (TS skips declarations)
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } } // โ
// Define exports without package.json subpath
"exports": { ".": "./dist/index.js" } // Tooling errors under bundler // โ
// Ship without files allow-list
// Tests, .env, and source files end up in tarball // โ
// Rely only on main and types
// Modern resolvers find nothing // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| TypeScript cannot find declarations | types not first in exports block | Move types to first position |
| Subpath import fails | No exports entry for that subpath | Add explicit subpath export |
| Tooling errors reading package.json | No ./package.json export | Add "./package.json": "./package.json" |
| CJS consumer cannot require package | No require condition | Add require pointing to .cjs file |
| Files missing from published tarball | files excludes dist | Add dist to files array |
| Legacy resolver fails | No main field | Keep main alongside exports |
Real-World Examples
1. Single ESM Entry
"exports": { ".": "./dist/index.js" }
2. Dual Format with Types
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } }
3. Subpath Export
"./utils": { "types": "./dist/utils.d.ts", "import": "./dist/utils.js" }
4. Wildcard Features
"./features/*": "./dist/features/*.js"
5. Expose package.json
"./package.json": "./package.json"
6. Files Allow-List
"files": ["dist"]
7. Engine Requirement
"engines": { "node": ">=18" }
8. Build Before Publish
"prepublishOnly": "npm run build"
9. Validate Resolver Compatibility
npx @arethetypeswrong/cli --pack .
10. Dry-Run Pack
npm pack --dry-run
Visual
Resolver Precedence
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ package.json โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ exports โ Modern Node, bundlers, TS โ
โ โ (highest) โ Conditional resolution โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ NOT PRESENT โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ module โ Bundlers only โ
โ โ โ ESM build for tree-shaking โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ NOT PRESENT โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ main โ Legacy Node, CDN tools โ
โ โ โ CJS or ESM entry โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ
โ types is read by TypeScript in parallel. โ
โ When exports exists, the types condition controls โ
โ declaration resolution for modern TS. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
exports Condition Ordering
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CORRECT ORDER WRONG ORDER โ
โ โ
โ { { โ
โ "types": "./index.d.ts", "import": "./index.js", โ
โ "import": "./index.js", "types": "./index.d.ts" โ
โ "require": "./index.cjs" } โ
โ } โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ TS matches โ โ TS matches โ โ
โ โ types first โ โ import first โ โ
โ โ Finds .d.ts โ โ Finds .js โ โ
โ โ โ โ No declarations โ โ
โ โ SUCCESS โ โ FAILURE โ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Resolvers walk top-down and stop at the first match. โ
โ types must always be first. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Dual Format Exports Map
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ exports: { โ
โ ".": { โ
โ "types": "./dist/index.d.ts", โ
โ "import": "./dist/index.js", โ ESM consumers โ
โ "require": "./dist/index.cjs" โ CJS consumers โ
โ }, โ
โ "./utils": { โ
โ "types": "./dist/utils.d.ts", โ
โ "import": "./dist/utils.js", โ
โ "require": "./dist/utils.cjs" โ
โ }, โ
โ "./package.json": "./package.json" โ tooling โ
โ } โ
โ โ
โ import 'my-lib' โ index.js โ
โ require('my-lib') โ index.cjs โ
โ import 'my-lib/utils' โ utils.js โ
โ import 'my-lib/internal' โ ERR_PACKAGE_PATH_NOT_EXPORTED โ
โ โ
โ Unlisted paths are inaccessible by design. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Validation Before Publish
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ BEFORE npm publish โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ npm pack โ Shows exact tarball contents โ
โ โ --dry-run โ Catches missing dist/ files โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ @arethetypes โ Simulates every consumer scenario โ
โ โ wrong/cli โ Node16 ESM, CJS, bundler, legacy โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ All resolvers โ Package works for every consumer โ
โ โ pass โ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ
โ A package that passes both checks resolves correctly โ
โ for Node, bundlers, and TypeScript. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
main | Legacy entry point; CJS or ESM |
module | Bundler convention; ESM build |
types | TypeScript declarations for single entry |
exports | Modern entry-point map; takes precedence |
| Condition order | types โ import โ require โ default |
| Subpath encapsulation | Unlisted paths throw ERR_PACKAGE_PATH_NOT_EXPORTED |
./package.json export | Required for tooling under bundler resolution |
files field | Allow-list of what ships in tarball |
| Validation | @arethetypeswrong/cli --pack . |
Key takeaways:
exportsis the authoritative entry-point definition in modern resolvers. Once it exists,mainandmoduleare fallbacks for legacy tools. The map defines the public API; unlisted subpaths are inaccessible by design .typesmust come first in every exports condition block. Resolvers stop at the first match. Ifimportcomes beforetypes, TypeScript resolves to the JavaScript file and fails to find declarations .- Expose
./package.jsonexplicitly. Tooling reads it for metadata, and without an export entry, bundler-resolution consumers error before they can import anything . - Keep
mainandmodulefor legacy resolvers.exportscovers modern consumers, but older Node versions and some CDN tools still readmain. The dual configuration costs nothing and prevents a class of compatibility failures . - Validate with
@arethetypeswrong/clibefore publishing. The tool simulates every consumer scenarioโNode16 ESM, Node16 CJS, bundler resolution, legacy nodeโand reports which ones fail. A package that passes resolves correctly for every consumer type . - Use
filesas an allow-list. An ignore file fails open; an allow-list fails closed."files": ["dist"]ships only the build output, excluding source files, tests, and any secrets that might be lying around .
Remember: The four fields are not alternatives to each other. They are layers of a compatibility strategy. main serves the oldest resolvers. module serves bundlers that predate exports. types serves TypeScript under legacy resolution. exports serves everything modern and defines what the package actually exposes. A package that sets all four correctly works for every consumer. A package that sets only main and types works for some consumers and fails silently for others. The validation stepโnpm pack --dry-run and @arethetypeswrong/cliโis what confirms that the configuration matches reality before the package is published to the registry.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!