Node.js Package Exports and Imports

Define Node.js package entry points with exports and internal imports, test private subpaths, and understand conditional ESM and CommonJS boundaries.

Lesson 36 of 43Intermediate10 min read

A package can contain many files while promising consumers access to only a few supported entry points. The exports field defines that public interface. The imports field supplies private mappings for code inside the package, keeping internal references independent of fragile relative path chains. This Node 24 guide builds complete temporary packages and runs real consumers against them. It extends ordinary import and export syntax into package design: which paths callers may use, how internal paths resolve, and what happens when import and require receive different implementations. No registry publication or third-party dependency is needed for the fixtures.

Declare a public API instead of exposing the file layout

The exports field maps package specifiers to target files. A mapping for the dot entry exposes the package's main interface, while named subpaths expose additional supported modules. When exports exists, a deep package import that is not mapped can fail with ERR_PACKAGE_PATH_NOT_EXPORTED even if the file exists on disk. This prevents consumers from depending accidentally on an internal directory structure that the author wants to change later.

Introducing exports to an existing package can be a breaking change if callers previously imported arbitrary deep paths. Inventory those paths and decide which remain supported. Prefer explicit entry points when the public API is small. Subpath patterns can reduce repetitive mappings, but they require careful target design and do not automatically create a stable API for every future file. The fixture exposes the main module and a math subpath while leaving a private source file inaccessible through the package name. The error tests the declared contract rather than file existence.

Use private imports for internal names

The imports field maps specifiers beginning with a hash sign. These names are intended for resolution within the declaring package. A mapping such as #settings can point to an internal module so sibling modules do not all repeat a relative path. This is a Node package resolution feature, not the browser's import-map syntax and not a TypeScript paths setting. Those systems can look similar while operating in different environments.

Targets and conditions must follow the documented package rules. Keep mappings narrow and choose names that communicate a module's role. An internal alias is useful during refactoring, but it should not conceal surprising side effects or global configuration. The fixture's main file imports #settings to obtain a fixed demo label. An external consumer imports only the documented package interface. Internal imports do not make files secret: code with filesystem access can still read them, and absolute file references are outside the purpose of the package-name boundary. Treat encapsulation as an API contract, not an access-control system.

Understand conditions and module formats

Conditional exports can select different targets for import, require, node, or other supported conditions. The ordering of condition keys matters because the resolver considers matching conditions according to the declared structure. Put a fallback default where appropriate and test the environments you claim to support. An import condition does not simply mean that a file extension must be .mjs; the target's module format is determined by the relevant extension and package configuration.

The type field influences how .js files are interpreted. .mjs explicitly identifies an ES module, and .cjs explicitly identifies CommonJS. Node 24 has additional interoperability behavior, but that is not a reason to assume every module can be synchronously required; top-level await and other boundaries matter. Use explicit formats in a teaching fixture so condition selection is visible. A package that selects separate ESM and CommonJS implementations can accidentally create two copies of mutable state. Keep shared singleton state carefully designed, or use one implementation with suitable wrappers when that better matches the API.

Test entry points from a consumer perspective

A source file can work through a relative import while its published entry point is broken. Test the package name and each supported subpath, including both import and require if both are promised. Also test expected rejections for private paths. Package self-reference allows code inside a named package to resolve that package's declared exports, which lets the dependency-free fixture exercise the resolver without creating node_modules or publishing to npm.

Exports does not replace package contents selection, licensing, versioning, or documentation. A private file can still be included in an archive even when package-name resolution does not expose it. Review what is actually distributed separately from what the resolver promises. Also keep bundler and browser support explicit; tools may implement additional conditions or transform modules differently. A successful Node fixture validates this Node contract, not every ecosystem tool. The examples create only temporary files, run consumers with the current Node executable, and remove the fixture directory after the child process exits.

Node.js documentation and version notes

Node.js Package Exports and Imports Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Resolving package exports, imports and self-reference

The complete fixture writes package.json, two public modules, an internal settings file, and a consumer. Its package has a name, so the consumer can use self-reference to import it through exports. The math entry point doubles six. The main entry imports its settings through the private mapping. The consumer then attempts an unmapped package subpath and prints the documented error code. All files are generated under a newly created temporary directory. The child consumer runs with Node 24, and cleanup removes only that generated directory. The output proves the resolver behavior rather than merely displaying a proposed manifest.

JavaScript
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
const dir = mkdtempSync(join(tmpdir(), 'package-exports-demo-'));
try {
  mkdirSync(join(dir, 'src'));
  writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture-api', type: 'module',
    exports: { '.': './src/index.js', './math': './src/math.js' },
    imports: { '#settings': './src/settings.js' } }));
  writeFileSync(join(dir, 'src/settings.js'), 'export default "demo";');
  writeFileSync(join(dir, 'src/index.js'), 'import label from "#settings"; export { label };');
  writeFileSync(join(dir, 'src/math.js'), 'export const double = n => n * 2;');
  writeFileSync(join(dir, 'consumer.mjs'), `import { label } from 'fixture-api';
import { double } from 'fixture-api/math';
console.log(label, double(6));
try { await import('fixture-api/src/settings.js'); }
catch (error) { console.log(error.code); }`);
  process.stdout.write(execFileSync(process.execPath, [join(dir, 'consumer.mjs')], { encoding: 'utf8' }));
} finally { rmSync(dir, { recursive: true, force: true }); }
Output
demo 12
ERR_PACKAGE_PATH_NOT_EXPORTED

Node.js Package Exports and Imports in Real Projects

Publish conditional ESM and CommonJS entry points

Build a second named package whose main export chooses an ESM implementation for import and a CommonJS implementation for require. The consumer uses both mechanisms from one .mjs file through createRequire. Each implementation returns a different fixed label so the selected condition can be seen directly. Real dual-format packages should normally provide equivalent behavior, so these labels are diagnostic teaching data rather than a recommended public difference. The exercise is fully independent and creates every required file. It checks conditional entry selection without introducing mutable shared state that would complicate the example's ownership guarantees.

JavaScript
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
const dir = mkdtempSync(join(tmpdir(), 'package-conditions-demo-'));
try {
  writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture-dual',
    exports: { '.': { import: './main.mjs', require: './main.cjs' } } }));
  writeFileSync(join(dir, 'main.mjs'), 'export default "esm";');
  writeFileSync(join(dir, 'main.cjs'), 'module.exports = "commonjs";');
  writeFileSync(join(dir, 'consumer.mjs'), `import value from 'fixture-dual';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
console.log(value);
console.log(require('fixture-dual'));`);
  process.stdout.write(execFileSync(process.execPath, [join(dir, 'consumer.mjs')], { encoding: 'utf8' }));
} finally { rmSync(dir, { recursive: true, force: true }); }
Output
esm
commonjs

Node.js Package Exports and Imports FAQ

Does exports prevent someone reading private files?

No. It restricts supported resolution through the package name, not filesystem access. A caller with access to the installed directory can inspect included files, and the package archive may still contain them. Use exports to define a stable public API and prevent accidental deep imports. Use appropriate permissions and avoid distributing secrets for actual confidentiality. Review archive contents separately from resolver mappings.

Is imports the same as a browser import map?

No. Package imports is a Node package.json field whose private names begin with # and resolve within that package. Browser import maps have a different configuration location and runtime. TypeScript paths is another distinct mechanism and does not automatically rewrite Node resolution. If a project uses several tools, configure each required environment explicitly rather than assuming one alias declaration controls all of them.

Why does a deep import fail when the file exists?

The package's exports contract can omit that subpath. Existence alone is insufficient for package-name resolution once the entry points are restricted. Import a documented public entry instead of depending on the internal layout. If you maintain the package and need to support an established path, add an intentional mapping and test it. Introducing restrictions to an existing public package may require a breaking version change.

Why can dual-format packages duplicate state?

Separate import and require targets can evaluate separate implementations with separate module caches and singleton objects. Consumers using both may then observe two counters, registries, or connection pools. Design equivalent stateless functions or share a carefully chosen underlying implementation. The worked labels merely show resolution paths. They do not demonstrate safe shared mutable state, which needs its own architectural and compatibility tests.

Node.js Package Exports and Imports Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choiceeasy

Which implementation does createRequire select in the worked dual package?

Node.js Package Exports and Imports Quiz

2 questions to check what you learned.

  1. What is the purpose of #settings in the main fixture?

    • A private package imports mapping
    • A URL fragment automatically downloaded by Node
    • A public subpath available from every external package
  2. Why test package-name imports rather than only relative source imports?

    • The resolver contract can fail while relative files still work
    • Relative imports always publish the package automatically
    • Package-name tests remove the need to check module formats