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 understandspackage.json
"exports"
/"imports"
)."module": "esnext"
(or"node16"
/"nodenext"
) — so TS doesn’t erase ESM semantics."target": "es2020"
or newer (so dynamicimport
and modern JS features are supported).
- If you use
"exports"
with conditions (like"import"
vs"require"
), TS can follow them only undernodenext
/node16
.
For subpath imports:
- Same as above:
"moduleResolution": "node16"
or"nodenext"
required. - You don’t need
paths
intsconfig.json
if you stick to"imports"
inpackage.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
- Always define
"exports"
in published packages- Prevents consumers from depending on internal file paths.
- Explicitly whitelist what’s public API.
- 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
).
- Prefer subpath imports over TypeScript
paths
- TS
paths
only affect compile-time, not runtime. "imports"
inpackage.json
works at both compile and runtime.
- TS
- 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.
- This aligns TS resolution with actual Node.js rules for
- 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"
ornode16
, andtarget: es2020+
for best compatibility. - Best practice: always define
exports
, useimports
overpaths
, 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 / Condition | Purpose |
---|---|
"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 withmoduleResolution: "nodenext"
.
5. Best practices
- Always include
"types"
if your package provides TypeScript definitions. - Use
"import"
and"require"
to support dual ESM/CJS consumers. - Use
"default"
as a fallback to avoid runtime errors if a consumer uses an unsupported module system. - Avoid platform-specific conditions unless needed; they are mostly useful for browser vs Node builds.
- 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.