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

Entry field precedence: exports-aware tools vs legacy TypeScript Two columns comparing resolution order: exports-aware tools check exports first and never fall back to main or module if it exists, while TypeScript's legacy node resolution mode ignores exports and reads types then main instead. Node.js / modern bundlers TypeScript legacy "node" mode 1. exports (if present) 2. main (fallback only) 3. module (bundlers only) exports fully overrides the rest 1. types (top-level field) 2. main (if types absent) 3. exports is never read requires node16/bundler to change

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: main was left pointed at a stale build while the exports map was updated, so users on exports-aware tooling get the fix and users on legacy tooling do not.

Fix: Treat main as a synchronized mirror of the exports require/default target, 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 exports at all — it relies entirely on resolve.mainFields, which defaults to ['browser', 'module', 'main']. If you drop module, webpack 4 consumers fall all the way back to main.
  • Vite reads exports by default but respects a resolve.mainFields override in vite.config.ts, which can reintroduce module-based resolution even in an exports-aware project.
  • pnpm does not change the precedence rules themselves, but its strict node_modules layout means a broken exports map 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 reads exports; only "node16", "nodenext", and "bundler" do.
  • CommonJS require.resolve and native ESM import.meta.resolve both use the exports map when present, so keeping main accurate 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.



↑ Back to package.json Fields for Distribution