declarations. This prevents accidental import of internal modules and enforces a strict public API boundary.
{
"name": "@acme/data-orchestrator",
"version": "2.4.1",
"type": "module",
"private": true,
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts"
},
"./utils": {
"import": "./dist/esm/utils.js",
"require": "./dist/cjs/utils.cjs",
"types": "./dist/types/utils.d.ts"
},
"./package.json": "./package.json"
},
"engines": {
"node": ">=20.0.0"
}
}
Rationale: type: "module" aligns with Node.js 20+ defaults and enables native import/export syntax. The exports field restricts access to only explicitly mapped paths, preventing consumers from importing internal implementation details. The ./package.json export is a Node.js convention that allows tooling to read package metadata without triggering full module resolution. Pinning engines to >=20.0.0 ensures compatibility with modern V8 features and native test runners.
Step 2: Dependency Governance and Version Strategy
Dependency ranges dictate update behavior. The caret (^) allows minor and patch updates, making it suitable for stable libraries. The tilde (~) restricts updates to patches only, ideal for tooling where minor versions may introduce breaking configuration changes. Exact versions should be reserved for lockfile-managed environments or critical infrastructure packages.
{
"dependencies": {
"pg": "^8.13.0",
"zod": "^3.23.8"
},
"devDependencies": {
"vitest": "~2.1.1",
"oxlint": "^0.15.0",
"tsx": "^4.19.0"
},
"peerDependencies": {
"react": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "^2.3.3"
}
}
Rationale: Production dependencies use ^ to receive security patches and non-breaking features. DevDependencies use ~ for test runners and linters to avoid unexpected configuration shifts. peerDependencies signal that the consuming project must provide react, preventing duplicate installations. optionalDependencies like fsevents are platform-specific and will not fail installation on Linux or Windows. The actual version pinning is handled by package-lock.json, which records the exact resolved tree including all transitive dependencies.
Step 3: Script Orchestration Architecture
Scripts should be composed into logical phases rather than monolithic commands. Lifecycle hooks automate repetitive tasks, while composition patterns enable parallel execution and environment injection.
{
"scripts": {
"preinstall": "node scripts/verify-node-version.mjs",
"postinstall": "npm run build:types",
"build:types": "tsc --emitDeclarationOnly --outDir dist/types",
"build:esm": "esbuild src/index.ts --bundle --format=esm --outfile=dist/esm/index.js --platform=node",
"build:cjs": "esbuild src/index.ts --bundle --format=cjs --outfile=dist/cjs/index.cjs --platform=node",
"build": "npm run build:types && npm run build:esm && npm run build:cjs",
"lint": "oxlint src/ --deny-warnings",
"format": "biome format --write src/",
"validate": "npm run lint && npm run format && npm run test",
"test": "vitest run",
"test:watch": "vitest watch",
"test:coverage": "vitest run --coverage",
"db:migrate": "tsx scripts/run-migrations.ts",
"db:seed": "tsx scripts/seed-database.ts",
"db:reset": "npm run db:migrate -- --force && npm run db:seed",
"docker:build": "docker build -t acme-orchestrator .",
"docker:up": "docker compose up -d --build",
"docker:down": "docker compose down -v",
"release": "standard-version --release-as minor",
"audit:check": "npm audit --audit-level=moderate",
"audit:fix": "npm audit fix"
}
}
Rationale:
preinstall and postinstall hooks enforce environment requirements and automate type generation after dependency installation.
- Build steps are split by output format to enable incremental compilation and parallel execution in CI.
validate composes linting, formatting, and testing into a single quality gate, preventing broken code from reaching merge requests.
- Database scripts use
tsx for native TypeScript execution without compilation overhead.
- Docker commands are abstracted to standardize container lifecycle management.
standard-version automates semantic versioning and changelog generation, aligning with the ^ range strategy.
Architecture Decision: Separating type checking from compilation is critical. TypeScript's --noEmit or --emitDeclarationOnly catches type errors without generating JavaScript, allowing faster feedback during development. Compilation should only occur after validation passes. This prevents wasted CI cycles on code that will fail type checks anyway.
Pitfall Guide
1. The Caret Range Trap
Explanation: Using ^ for all dependencies assumes semantic versioning is strictly followed. Many packages introduce breaking changes in minor versions, causing CI failures or runtime errors.
Fix: Reserve ^ for stable, well-maintained libraries. Use ~ for tooling, linters, and test runners. Pin exact versions for critical infrastructure packages. Always review package-lock.json diffs after updates.
Explanation: Shell commands like rm -rf or mkdir -p fail on Windows. Developers on macOS or Linux may not notice until a teammate or CI runner breaks.
Fix: Use cross-platform utilities like rimraf, mkdirp, or shx. Alternatively, leverage Node.js native fs and child_process modules in script files. Avoid shell-specific syntax in package.json.
3. Lockfile Neglect
Explanation: Ignoring package-lock.json or editing it manually creates divergent dependency trees. Teams experience "works on my machine" bugs when transitive dependencies resolve differently.
Fix: Commit the lockfile to version control. Use npm ci in CI/CD pipelines for deterministic, fast installs. Never edit the lockfile manually; let npm reconcile it during npm install.
4. Lifecycle Hook Overload
Explanation: Overusing preinstall, postinstall, or prepublishOnly can slow down installations, cause infinite loops, or fail in restricted CI environments.
Fix: Keep lifecycle hooks lightweight. Move complex logic to dedicated script files. Use prepublishOnly instead of prepublish to avoid running during local installs. Test hooks in clean environments before committing.
5. Missing Private Flag
Explanation: Publishing internal or private packages to the public npm registry exposes proprietary code and creates namespace conflicts.
Fix: Always set "private": true for application projects and internal libraries. Use scoped packages (@org/name) for private registry publishing. Verify the flag before running npm publish.
6. Engine Field Omission
Explanation: Running code on unsupported Node.js versions causes runtime errors, missing APIs, or security vulnerabilities. Teams waste time debugging environment mismatches.
Fix: Define "engines": { "node": ">=20.0.0" } and enforce it with npm install --engine-strict in CI. Use .nvmrc or fnm files to standardize local development versions.
7. Transitive Dependency Blindness
Explanation: Developers focus only on direct dependencies, ignoring transitive packages that may contain vulnerabilities or bloat. This leads to security gaps and unnecessary disk usage.
Fix: Run npm audit regularly and configure Dependabot or Renovate for automated PRs. Use npm ls to audit the dependency tree. Remove unused direct dependencies to shrink the transitive surface area.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single application project | npm with strict lockfile enforcement | Native tooling, zero configuration, predictable CI | Low (no extra tooling) |
| Monorepo with 5+ packages | pnpm with workspaces | Content-addressable storage reduces disk usage by 60-70% | Medium (learning curve) |
| Enterprise CI/CD pipeline | npm ci + --engine-strict | Guarantees deterministic builds and version compliance | Low (CI time optimization) |
| Library targeting ESM/CJS consumers | exports field with conditional maps | Prevents internal module exposure and supports dual packaging | Low (initial setup) |
| High-security compliance | Exact versions + npm audit --audit-level=high | Eliminates range-based drift and blocks vulnerable transitive deps | Medium (manual review overhead) |
Configuration Template
{
"name": "@acme/project-core",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
},
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts"
}
},
"scripts": {
"preinstall": "node scripts/check-env.mjs",
"postinstall": "npm run build:types",
"build:types": "tsc --emitDeclarationOnly --outDir dist/types",
"build": "npm run build:types && esbuild src/index.ts --bundle --format=esm --outfile=dist/esm/index.js --platform=node",
"validate": "oxlint src/ --deny-warnings && biome format --write src/ && vitest run",
"test": "vitest run",
"test:watch": "vitest watch",
"docker:up": "docker compose up -d --build",
"docker:down": "docker compose down -v",
"release": "standard-version --release-as patch"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"vitest": "~2.1.1",
"oxlint": "^0.15.0",
"tsx": "^4.19.0",
"esbuild": "^0.24.0",
"typescript": "^5.6.0"
}
}
Quick Start Guide
- Initialize with strict defaults: Run
npm init -y, then immediately set "private": true, "type": "module", and "engines": { "node": ">=20.0.0" }.
- Configure conditional exports: Add an
exports map pointing to your build output directories. Reserve main only for legacy CommonJS compatibility.
- Install dependencies with ranges: Use
npm install <pkg> for production deps (defaults to ^) and npm install -D <pkg> for dev tools. Review package-lock.json before committing.
- Compose your first quality gate: Add a
validate script that runs linting, formatting, and testing sequentially. Run npm run validate before every commit.
- Enforce in CI: Replace
npm install with npm ci in your pipeline configuration. Add --engine-strict to catch version mismatches early.