main, module, and exports Field Precedence
Which package.json entry field wins — main, module, or exports — across Node.js, webpack, Vite, and TypeScript, and how to avoid conflicting resolution.
A library author sets main, module, and exports all at once, expecting one clean fallback chain — then discovers that Node.js loads one file, webpack loads another, and TypeScript loads a third, each silently, with no error to flag the mismatch. Understanding exactly which field each resolver consults, and in what order, is the difference between a package that behaves identically everywhere and one that ships subtly different code depending on the consumer’s toolchain. This precedence question sits directly underneath the distribution fields overview — this page answers the “which one wins” question in detail.
Root Cause
Node.js added the exports field in v12.7 as a stricter, conditional replacement for main. When exports is present, Node.js’s resolution algorithm stops consulting main for the entry point entirely — it isn’t a fallback chain, it’s a hard override. module, meanwhile, was never a Node.js field at all; it was a community convention invented by Rollup and adopted by webpack to identify ESM builds for tree-shaking, and Node.js has never read it. TypeScript adds a third axis: its legacy moduleResolution: "node" mode predates exports support and reads only the top-level types field (or main if types is absent), while moduleResolution: "node16" or "bundler" reads exports like Node.js does. The result is three independent resolution algorithms, each with its own precedence rule, all reading the same package.json.
Minimal Reproduction
This package.json resolves to three different files depending on which tool loads it:
{
"name": "precedence-demo",
"main": "./dist/legacy.cjs",
"module": "./dist/legacy.esm.js",
"types": "./dist/legacy.d.ts",
"exports": {
".": {
"types": "./dist/modern.d.ts",
"import": "./dist/modern.mjs",
"require": "./dist/modern.cjs",
"default": "./dist/modern.mjs"
}
}
}
node -e "console.log(require.resolve('precedence-demo'))"
# -> resolves through exports: dist/modern.cjs
npx webpack --entry precedence-demo --mode production --dry-run
# -> resolves through exports (webpack 5+): dist/modern.mjs
npx tsc --moduleResolution node --noEmit
# -> resolves through legacy "types": dist/legacy.d.ts (exports ignored)
The same package name resolves to modern.cjs, modern.mjs, and legacy.d.ts in the same install, purely based on which tool is asking.
SVG: Precedence Chain by Tool
Step-by-Step Fix
Step 1 — Audit for divergent targets
Print what each field resolves to relative to the package root:
node -e "
const p = require('./package.json');
console.log({ main: p.main, module: p.module, types: p.types, exportsDot: p.exports?.['.'] });
"
Flag any case where main, the exports default condition, and types point at logically different builds (not just different filenames for the same build).
Step 2 — Align main with the exports fallback
{
"main": "./dist/legacy.cjs",
- "exports": {
- ".": {
- "import": "./dist/modern.mjs",
- "require": "./dist/modern.cjs"
- }
- }
+ "exports": {
+ ".": {
+ "types": "./dist/modern.d.ts",
+ "import": "./dist/modern.mjs",
+ "require": "./dist/modern.cjs",
+ "default": "./dist/modern.mjs"
+ }
+ }
}
After: point main at the same CJS artifact the require condition uses, so legacy tools and modern tools serve identical code.
{
"main": "./dist/modern.cjs",
"exports": {
".": {
"types": "./dist/modern.d.ts",
"import": "./dist/modern.mjs",
"require": "./dist/modern.cjs",
"default": "./dist/modern.mjs"
}
}
}
HAZARD PREVENTION
Symptom: A bug fix ships but only some users see it.
Root cause:
mainwas left pointed at a stale build while theexportsmap was updated, so users onexports-aware tooling get the fix and users on legacy tooling do not.Fix: Treat
mainas a synchronized mirror of theexportsrequire/defaulttarget, never an independent build.
Step 3 — Synchronize the top-level types field
{
"types": "./dist/modern.d.ts"
}
Expected result: tsc --moduleResolution node (the pre-4.7 default some consumers still use) resolves the same declarations as moduleResolution: "bundler" does via exports.
Step 4 — Verify module points at an ESM-equivalent build
{
"module": "./dist/modern.mjs"
}
Expected result: bundlers that read resolve.mainFields: ['module', 'main'] (a common webpack/Vite config for tree-shaking) load the same logical code as bundlers that read exports.
Verification
# Node.js resolution
node -e "console.log(require.resolve('precedence-demo'))"
# TypeScript legacy resolution
npx tsc --moduleResolution node --noEmit --traceResolution 2>&1 | grep precedence-demo
# TypeScript modern resolution
npx tsc --moduleResolution bundler --noEmit --traceResolution 2>&1 | grep precedence-demo
# Static cross-check of all fields at once
npx publint --strict
Expected publint output when every field agrees:
✓ "main" and exports["."]["require"] resolve to equivalent output
✓ "module" and exports["."]["import"] resolve to equivalent output
✓ No issues found
Edge Cases / Gotchas
- webpack 4 and earlier never reads
exportsat all — it relies entirely onresolve.mainFields, which defaults to['browser', 'module', 'main']. If you dropmodule, webpack 4 consumers fall all the way back tomain. - Vite reads
exportsby default but respects aresolve.mainFieldsoverride invite.config.ts, which can reintroducemodule-based resolution even in anexports-aware project. - pnpm does not change the precedence rules themselves, but its strict
node_moduleslayout means a brokenexportsmap fails faster (no accidental phantom resolution through hoisted packages) than the same mistake under npm’s flatter layout. - TypeScript
moduleResolution: "node10"(the renamed legacy"node"value in TypeScript 5) never readsexports; only"node16","nodenext", and"bundler"do. - CommonJS
require.resolveand native ESMimport.meta.resolveboth use theexportsmap when present, so keepingmainaccurate matters more for third-party tooling (test runners, bundlers) than for plain Node.js scripts.
Frequently Asked Questions
Does exports always win over main and module?
In Node.js 12.7 and later, and in bundlers that implement the Node exports resolution algorithm (webpack 5+, Vite 3+, Rollup 3+, esbuild), yes — exports is checked first and, if present, main and module are never consulted for the entry point. Older tooling that predates exports support falls back to main or module entirely.
Why does my bundle include the wrong format even though exports looks correct?
Check whether the bundler’s resolve.mainFields configuration lists module or main ahead of respecting exports, or whether an older bundler version does not implement the exports algorithm at all and is silently falling back to main.
Can I omit main and module entirely once exports is set?
Only if every consumer you support runs Node.js 12.7+, or a modern bundler/TypeScript version that reads exports. Any tool that predates exports support will fail to resolve the package at all without a main fallback, so most published libraries keep it for broad compatibility.
Does TypeScript follow the same precedence as Node.js?
Only under moduleResolution "node16" or "bundler". Under the legacy "node" mode, TypeScript ignores exports entirely and reads the top-level types field (falling back to main if types is absent), regardless of what exports contains.
What happens if main and the exports default condition point to different files?
Consumers resolving through exports get one file and consumers falling back to main get another, silently. This is a common source of divergent behavior reports; always keep main pointed at the same logical build as the exports default condition.
Related
- package.json Fields for Distribution — the full set of distribution fields this precedence question sits within.
- Mastering the package.json Exports Field — condition ordering and sub-path patterns inside the
exportsmap itself. - Navigating the Dual-Package Hazard — the deeper architectural risk when
mainandexportsdisagree on format.