One common source of confusion when building npm libraries is this:
“If I only define
maininpackage.json, myindex.tsbecomes the public API. So how do deep imports magically start working when I import deeper paths?”
The answer lies in how Node resolves packages when exports is missing. Let’s break it down.
The key idea
If a package does not define
exports, Node falls back to filesystem-based resolution.
That fallback is exactly what allows deep imports to work.
Deep imports aren’t something you enable — they’re what happens when nothing explicitly disables them.
Case 1: Package with only main
Consider a library with this package.json:
{
"name": "@acme/utils",
"main": "dist/index.js"
}What main actually does
It defines the default entry point for the package.
When someone writes:
import { sum } from "@acme/utils";Node resolves it as:
@acme/utils → dist/index.jsThat’s it. main says nothing about any other files in the package.
Why deep imports start working automatically
Now imagine your published package structure looks like this:
@acme/utils/
dist/
index.js
math.js
internal/
helper.jsBecause exports is missing, Node uses legacy resolution rules:
Any file inside the package root can be imported by path.
So all of the following work:
import { sum } from "@acme/utils/dist/math";
import helper from "@acme/utils/dist/internal/helper";Why?
Because Node simply checks:
node_modules/@acme/utils/dist/math.jsIf the file exists → the import succeeds.
There is no concept of public vs private API without exports.
Where index.ts fits in
When people say:
“
index.tsis my public API”
That’s true only by convention, not enforcement.
mainpoints todist/index.jsConsumers usually import the package root
But nothing prevents them from importing deeper files
So with only main defined:
Import | Why it works |
|---|---|
|
|
| filesystem lookup |
| filesystem lookup |
Deep imports work simply because Node is allowed to see everything.
Case 2: What changes when exports is added
Now update package.json:
{
"main": "dist/index.js",
"exports": {
".": "./dist/index.js"
}
}This single change switches Node into modern resolution mode.
What that means:
Only paths explicitly listed in
exportsare accessibleEverything else is blocked by default
Now:
import { sum } from "@acme/utils"; // ✅ worksBut:
import { sum } from "@acme/utils/dist/math"; // ❌ failsYou’ll get:
ERR_PACKAGE_PATH_NOT_EXPORTEDAdding exports acts like a firewall around your package.
Why deep imports feel “automatic”
Deep imports aren’t a feature — they’re a side effect.
They work because:
The files exist
Node can see them
No rule says “you can’t import this path”
Once exports is present, Node stops guessing and starts enforcing.
The mental model that actually sticks
Without exports
📦 Package
├─ main → default entry
└─ everything else → reachable by pathWith exports
📦 Package
└─ only what exports lists existsSummary
maindefines only the default entry point of a packageWithout
exports, Node allows filesystem-based deep importsAny file under the package root can be imported by path
index.tsis public by convention, not enforcementDeep imports work because nothing blocks them
Adding
exportsswitches Node to explicit, whitelist-based resolutionexportsis the only way to truly define and protect a public API
