Conditional Exports for Development vs Production
Map custom condition keys to dev and prod build artifacts, fix ERR_PACKAGE_PATH_NOT_EXPORTED when NODE_ENV is absent, configure Webpack and Vite conditionNames, and validate both environments with publint.
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 PREVENTION —
ERR_PACKAGE_PATH_NOT_EXPORTEDRoot 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.
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 meansnode_modules/my-libmay not be directly resolvable from your test script path. Usenode --conditions=development -e "import('my-lib/utils')"from a workspace package that hasmy-libas 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 setcustomConditions: ["development"]intsconfig.json. -
Vite dev server vs Vite build:
vite serveruns in development mode and setsmode = 'development';vite buildsetsmode = 'production'. If you passconditionsin 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_ENVauto-injection: Webpack’sDefinePluginsetsprocess.env.NODE_ENVin the compiled bundle but does NOT add a matching entry toresolve.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.
Related
- Mastering the package.json
exportsfield — the parent guide covering the fullexportsmap syntax, subpath patterns, and how condition resolution interacts with"imports". - Navigating the dual-package hazard — explains singleton bifurcation when the same package is loaded as both ESM and CJS, which conditional exports can inadvertently cause.
- Fixing require() errors in pure ESM packages — sibling page covering
ERR_REQUIRE_ESMand how to add a"require"condition branch safely.