Subpath imports and exports in package.json

Let’s take a look at subpath imports and exports in Node.js, how they interact with TypeScript compiler settings, and what the practical use cases and best practices are.

1. What are subpath imports and exports?

Subpath Exports

In a package’s package.json, the "exports" field controls what module specifiers are valid and what file they map to.

Example (package.json):

{
  "name": "my-lib",
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js",
    "./features/*": "./dist/features/*.js"
  }
}
  • import "my-lib"./dist/index.js
  • import "my-lib/utils"./dist/utils.js
  • import "my-lib/features/foo"./dist/features/foo.js

This prevents consumers from reaching into arbitrary internal files (my-lib/dist/...) and ensures only the explicitly declared subpaths are accessible.

Subpath Imports

This is for internal convenience inside a package.

Example (package.json):

{
  "imports": {
    "#utils": "./src/utils/index.js",
    "#config/*": "./src/config/*.js"
  }
}
  • import "#utils"./src/utils/index.js
  • import "#config/dev"./src/config/dev.js

This is only for your package’s own code, not consumers. Think of it like “package-private aliases” without needing TypeScript paths.

2. Requirements in Node.js and TypeScript

Node.js requirements

  • Subpath exports/imports are supported in Node.js ≥ 12.20 (stable from Node 14+).
  • For ESM packages ("type": "module"), resolution is strictly controlled by "exports" (no deep imports without explicit mappings).
  • For CJS packages ("type": "commonjs"), "exports" also works, but CJS resolution rules apply if "exports" is missing.

TypeScript requirements

To make this work seamlessly in TypeScript, you need the right compiler settings:

For subpath exports:

  • Consumers of your package rely on Node’s rules.
  • In your own repo, TypeScript should be able to resolve them. This means:
    • "moduleResolution": "node16" or "nodenext" (so TS understands package.json "exports"/"imports").
    • "module": "esnext" (or "node16" / "nodenext") — so TS doesn’t erase ESM semantics.
    • "target": "es2020" or newer (so dynamic import and modern JS features are supported).
  • If you use "exports" with conditions (like "import" vs "require"), TS can follow them only under nodenext/node16.

For subpath imports:

  • Same as above: "moduleResolution": "node16" or "nodenext" required.
  • You don’t need paths in tsconfig.json if you stick to "imports" in package.json.

3. Use Cases

Subpath Exports

  • Encapsulation: Expose only intended entry points, hide internals.
  • Organized API surface: Allow users to import small parts without pulling the whole lib. import { parse } from "date-lib/parser";
  • Dual entry points (e.g., ESM vs CJS): "exports": { ".": { "import": "./esm/index.js", "require": "./cjs/index.js" } }

Subpath Imports

  • Internal aliases for large monorepos/packages: import "#utils/math"; // resolves cleanly
  • Safer refactors: No more fragile ../../../../utils imports.
  • Consistent with runtime: Unlike TS paths, these work in Node.js at runtime without extra tooling.

4. Best Practices

  1. Always define "exports" in published packages
    • Prevents consumers from depending on internal file paths.
    • Explicitly whitelist what’s public API.
  2. Use subpath exports for modular imports
    • But don’t go overboard — too many subpaths can fragment the API surface.
    • Expose a small number of stable “entry points” (e.g. main, cli, utils).
  3. Prefer subpath imports over TypeScript paths
    • TS paths only affect compile-time, not runtime.
    • "imports" in package.json works at both compile and runtime.
  4. Choose moduleResolution: "nodenext" (or "node16") in new TypeScript projects
    • This aligns TS resolution with actual Node.js rules for exports/imports.
    • Avoid legacy "node" mode unless you must support very old Node versions.
  5. Keep "type" consistent
    • "type": "module" → use ESM throughout, exports is mandatory for good DX.
    • "type": "commonjs" → you can still use exports, but ecosystem momentum is toward ESM.

Summary:

  • Subpath exports: control what your consumers can import.
  • Subpath imports: let your package use runtime-safe import aliases.
  • TypeScript: requires moduleResolution: "node16"/"nodenext", module: "esnext" or node16, and target: es2020+ for best compatibility.
  • Best practice: always define exports, use imports over paths, and align your TS config with modern Node ESM resolution.

Conditional Exports

Node.js’s subpath exports are much more than just path aliases. You can define conditional exports, which allow the package author to expose different files depending on the environment, module system, or feature being used.

Exactly — Node.js’s subpath exports are much more than just path aliases. You can define conditional exports, which allow the package author to expose different files depending on the environment, module system, or feature being used. Let’s go in detail.

1. Basic syntax

In package.json, "exports" can be:

{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  }
}
  • This is the simplest form: a string path.
  • Can also be an object, where keys are conditions.

2. Conditional exports

A conditional export looks like this:

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}

Here’s what the fields mean:

Field / ConditionPurpose
"import"Used for ESM import statements. Node will pick this file when a consumer does import ... and is running in ESM context.
"require"Used for CJS require(). Node will pick this file when consuming via CJS.
"types"Points to TypeScript type definitions. This tells TS where to find .d.ts files for this entry point.
"default"Fallback file if no other condition matches. Usually points to a common JS file.
"node"Platform-specific condition — file will be picked if running in Node.js.
"deno"Platform-specific — for Deno runtime.
"browser"Platform-specific — for browser runtime. Can also be "node"/"browser" combo.
"development" / "production"Environment conditions (less commonly used, supported in Node 20+).
"node-addons"Experimental — allows specifying a native addon variant.

Example combining conditions:

{
  "exports": {
    ".": {
      "import": "./esm/index.mjs",
      "require": "./cjs/index.cjs",
      "types": "./types/index.d.ts",
      "node": "./node/index.js",
      "browser": "./browser/index.js",
      "default": "./index.js"
    }
  }
}
  • Node evaluates conditions in order and picks the first one that matches.
  • Conditions like "import" vs "require" are most common; "types" is only used by TypeScript.

3. Subpath exports for directories

You can also do nested exports:

{
  "exports": {
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs",
      "types": "./dist/utils.d.ts"
    },
    "./features/*": {
      "import": "./dist/features/*.mjs",
      "require": "./dist/features/*.cjs",
      "types": "./dist/features/*.d.ts"
    }
  }
}
  • The "*" allows wildcard subpath exports, which is useful for libraries with many modules.
  • Consumers can then do: import { foo } from "my-lib/features/foo"; without worrying about CJS/ESM differences.

4. imports field for internal aliases

  • "imports" is like "exports", but only available inside your package.
  • Supports the same conditional syntax.
  • Example:
{
  "imports": {
    "#internal/*": {
      "import": "./src/internal/*.js",
      "require": "./src/internal/*.cjs"
    }
  }
  • Lets you avoid relative imports like ../../../utils, and these are resolved at runtime and by TypeScript with moduleResolution: "nodenext".

5. Best practices

  1. Always include "types" if your package provides TypeScript definitions.
  2. Use "import" and "require" to support dual ESM/CJS consumers.
  3. Use "default" as a fallback to avoid runtime errors if a consumer uses an unsupported module system.
  4. Avoid platform-specific conditions unless needed; they are mostly useful for browser vs Node builds.
  5. Wildcard exports ("./features/*") make modular APIs easier to maintain, but don’t overuse — each subpath must be stable.

Summary:

  • Subpath exports can be strings (simple paths) or objects (conditional exports).
  • Common conditions:
    • "import" → ESM import
    • "require" → CJS require
    • "types" → TypeScript type declarations
    • "default" → fallback
    • "node", "browser", "deno" → platform
    • "development", "production" → environment
  • Nested or wildcard exports let you expose multiple modules safely.
  • imports is for internal aliases only, but uses the same conditional syntax.

About Author

Mathias Bothe To my job profile

I am Mathias from Heidelberg, Germany. I am a passionate IT freelancer with 15+ years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I create Bosycom and initiated several software projects.