You add "development" and "production" keys to your exports field expecting Node.js to pick the right artifact automatically — then node my-script.js throws ERR_PACKAGE_PATH_NOT_EXPORTED because neither condition fires. The problem is that Node.js has no awareness of NODE_ENV; it only evaluates the condition strings it is explicitly told to check. Without that wiring at every layer — the resolver, the bundler, and CI — your carefully structured exports map silently falls through to "default" or errors out entirely.

Root Cause Explanation

Node.js conditional exports are a string-matching mechanism, not an environment-variable reader. When Node.js resolves an import it walks the condition keys in your exports map from top to bottom and stops at the first key whose string appears in its active conditions set. That set is populated by --conditions flags or by whatever the calling bundler injects — it is never populated from process.env.NODE_ENV automatically.

This is by design: the module system fundamentals separate the concept of “which format to load” from “what the application considers its environment”. A "development" key in exports will match only when something explicitly adds the string "development" to the resolver’s active conditions — nothing does that by default.

HAZARD PREVENTIONERR_PACKAGE_PATH_NOT_EXPORTED Root cause: no condition key matched AND no "default" fallback exists. Fix: add "default": "./dist/index.mjs" as the final key in every export branch.

Minimal Reproduction

The smallest package.json that demonstrates the silent fallthrough (and the crash when "default" is also missing):

{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    "./utils": {
      "development": "./src/dev-utils.js",
      "production": "./dist/prod-utils.mjs"
    }
  }
}

Running node -e "import('./node_modules/my-lib/utils')" throws:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './utils' is not defined
by "exports" in .../my-lib/package.json

Neither "development" nor "production" is in Node.js’s default condition set (import, require, default), so both keys are skipped and the map is exhausted with no match.

Step-by-Step Fix

1 — Add a "default" fallback to every branch

Before:

"./utils": {
  "development": "./src/dev-utils.js",
  "production": "./dist/prod-utils.mjs"
}

After:

"./utils": {
  "types": "./dist/types.d.ts",
  "development": "./src/dev-utils.js",
  "production": "./dist/prod-utils.mjs",
  "default": "./dist/prod-utils.mjs"
}

"types" must be first — TypeScript stops at the first matching condition and resolves types from that entry. "default" must be last — it is the catch-all that prevents ERR_PACKAGE_PATH_NOT_EXPORTED when neither "development" nor "production" is active.

2 — Activate the condition in Node.js directly

Pass --conditions via NODE_OPTIONS or the CLI flag:

# activate development condition for a single run
NODE_OPTIONS="--conditions=development" node my-script.js

# activate production condition
NODE_OPTIONS="--conditions=production" node my-script.js

Both flags stack with Node.js’s built-in conditions (import, require, default), so you do not need to re-list them.

3 — Configure Webpack to inject the condition

Webpack evaluates resolve.conditionNames left-to-right. Add "development" (or "production") before "import":

Before:

// webpack.config.ts
export default {
  resolve: {
    conditionNames: ['import', 'module', 'require', 'default']
  }
};

After:

// webpack.config.ts
export default {
  resolve: {
    conditionNames: [
      process.env.NODE_ENV === 'development' ? 'development' : 'production',
      'import',
      'module',
      'require',
      'default'
    ]
  }
};

4 — Configure Vite to inject the condition

Vite evaluates resolve.conditions left-to-right. Add the environment condition before "browser":

Before:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    conditions: ['browser', 'module', 'default']
  }
});

After:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig(({ mode }) => ({
  resolve: {
    conditions: [
      mode === 'development' ? 'development' : 'production',
      'browser',
      'module',
      'default'
    ]
  }
}));

5 — Configure Rollup via @rollup/plugin-node-resolve

// rollup.config.ts
import resolve from '@rollup/plugin-node-resolve';

const isDev = process.env.NODE_ENV === 'development';

export default {
  plugins: [
    resolve({
      exportConditions: [
        isDev ? 'development' : 'production',
        'default'
      ]
    })
  ]
};

Condition Resolution Order — Visual Overview

The SVG below shows how Node.js walks the exports map for a "./utils" import when three different condition sets are active.

Conditional exports resolution order for ./utils Three columns showing the active conditions set and which key matches first in the exports map for each scenario. plain node --conditions=development bundler (production) types development production default no match — skip no match — skip no match — skip MATCH → default no match — skip MATCH → dev build not reached not reached no match — skip no match — skip MATCH → prod build not reached matched condition skipped (not in active set) not reached (earlier key matched)

Verification Command

Run these checks before npm publish to confirm both condition paths resolve:

# structural validation
npx publint --strict

# verify the production path resolves
node --conditions=production \
  -e "import('./node_modules/my-lib/utils').then(m => console.log('prod OK:', Object.keys(m)))" \
  --input-type=module

# verify the development path resolves
node --conditions=development \
  -e "import('./node_modules/my-lib/utils').then(m => console.log('dev OK:', Object.keys(m)))" \
  --input-type=module

Expected output when both paths are wired correctly:

prod OK: [ 'doSomething', 'VERSION' ]
dev OK: [ 'doSomething', 'VERSION', 'debugHooks' ]

If publint --strict reports "development" or "production" as unrecognised custom conditions, that is a warning, not an error — publint flags non-standard keys but does not block publishing.

TypeScript validation — run once per condition set if you ship separate declaration files:

# check that the production types are consistent
tsc --noEmit --moduleResolution bundler

Edge Cases / Gotchas

  • pnpm with shamefully-hoist=false: pnpm’s strict symlink layout means node_modules/my-lib may not be directly resolvable from your test script path. Use node --conditions=development -e "import('my-lib/utils')" from a workspace package that has my-lib as a direct dependency, not from the monorepo root.

  • TypeScript moduleResolution: "bundler" vs "node16": With "node16", TypeScript only activates the "types", "import", "require", and "default" conditions by default. Your "development" key will never match during type checking unless you set customConditions: ["development"] in tsconfig.json.

  • Vite dev server vs Vite build: vite serve runs in development mode and sets mode = 'development'; vite build sets mode = 'production'. If you pass conditions in a flat object (not a function), both modes receive the same condition list — use the config factory form (defineConfig(({ mode }) => (…))) to vary them.

  • Webpack NODE_ENV auto-injection: Webpack’s DefinePlugin sets process.env.NODE_ENV in the compiled bundle but does NOT add a matching entry to resolve.conditionNames. The two are independent; you must set both.

  • ERR_UNSUPPORTED_DIR_IMPORT: Condition values must point to explicit files, not directories. "development": "./src/" triggers this error in ESM. Always terminate the path with a file extension: "development": "./src/dev-utils.js".

  • Condition key ordering across subpaths: If you have multiple subpaths (".", "./utils", "./internals"), the key order must be consistent across all of them. Inconsistent ordering is not an error today but will produce confusing behaviour if Node.js ever changes its traversal strategy.

  • Stacking custom conditions: You can activate multiple conditions at once — --conditions=development --conditions=browser — and the exports map will match whichever key appears first in the map AND is in the active set. Design the map so the most-specific condition key appears before the more-general ones.

FAQ

Why does NODE_ENV=development node my-script.js still throw ERR_PACKAGE_PATH_NOT_EXPORTED?

NODE_ENV is a shell environment variable; Node.js’s module resolver never reads it. You must pass --conditions=development (or set NODE_OPTIONS="--conditions=development") to add that string to the active condition set.

After adding "default", my bundler still loads the dev build in production. Why?

Check resolve.conditionNames (Webpack) or resolve.conditions (Vite). If "development" appears in that array unconditionally, the bundler activates it regardless of NODE_ENV. Wrap the condition in a ternary gated on process.env.NODE_ENV or the Vite mode parameter.

Why does TypeScript pick the wrong types after I added "development" before "types"?

TypeScript’s condition resolver stops at the first matching key. If "development" is in your active customConditions and it appears before "types", TypeScript reads the .js source file and infers types from it rather than using your .d.ts. Always put "types" first in every export branch.

Can consumers opt in to the development build without me shipping it publicly?

Yes — mark the dev artifacts in .npmignore if you want them excluded from the published tarball, or set the "development" value to the same path as "default" and only use the condition split internally in your own test harness. Be aware: if you ship the "development" key but omit the file, consumers who activate the condition will get ERR_MODULE_NOT_FOUND.

Does publint validate custom condition keys?

publint --strict warns about unrecognised custom conditions (anything outside the WHATWG-defined set: import, require, node, browser, default, types). The warning is informational — your package will still publish and work. Run npx publint without --strict to suppress the warning if you deliberately use "development" / "production" as custom keys.


↑ Back to Mastering the package.json exports field