Using Subpath Imports with the imports Field
Deep relative imports such as ../../../utils/logger.js make refactors painful, and the usual fix — a paths alias in tsconfig.json — only works in the compiler, not at runtime. The imports field in package.json is Node.js's built-in answer: private, #-prefixed specifiers that resolve inside your package, work in Node.js, TypeScript and every major bundler, and can switch targets by environment. This guide sets them up, explains the resolution rules, and covers the errors you will hit on the way.
Exact symptoms and error messages
Teams usually arrive here from one of three failures. The first is an alias that type-checks but crashes at runtime because paths is compile-time only:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@/utils' imported from /app/dist/server.js
The second is a subpath import that is referenced but not declared, or declared without a matching condition:
TypeError [ERR_PACKAGE_IMPORT_NOT_DEFINED]: Package import specifier "#logger" is not defined in package /app/package.json imported from /app/src/server.js
The third is TypeScript rejecting the specifier because the project uses a resolver that does not read imports:
error TS2307: Cannot find module '#logger' or its corresponding type declarations.
Each has a distinct cause: the first is using the wrong mechanism, the second is a missing key, and the third is a compiler setting.
Root cause analysis
imports is the private counterpart of exports. Where the exports map describes what other packages may import from you, imports describes aliases your own files may use. Specifiers must start with #, which keeps them from ever colliding with a package name, and they resolve relative to the nearest package.json — so each workspace package gets its own set.
Because the lookup is performed by the runtime, the same alias works in node src/server.js, in the built output, in Vitest and Jest, and in webpack, Vite, esbuild and Rollup, all of which implement the field. That is the decisive difference from tsconfig paths, which rewrite nothing — TypeScript assumes something else will make the alias resolve at runtime.
TypeScript itself understands imports under moduleResolution node16, nodenext and bundler. Under the legacy node10 resolver it does not, which is the source of the TS2307 error above.
Resolution and configuration patch
Declare the aliases in the package's own package.json. Point them at built output for production and let a condition swap to source for development if you want to run without a build:
{
"name": "@acme/api",
"type": "module",
"imports": {
"#logger": "./src/lib/logger.js",
"#config": {
"development": "./src/config/dev.js",
"default": "./src/config/prod.js"
},
"#db/*": "./src/db/*.js",
"#platform": {
"node": "./src/platform/node.js",
"browser": "./src/platform/browser.js"
}
}
}
Then import them anywhere inside the package:
import { log } from '#logger';
import { config } from '#config';
import { usersTable } from '#db/users';
Implementation steps:
- Pick a naming scheme. One level of
#areaor#area/*keys is enough; mirroring your folder structure keeps them predictable. - Always include the file extension in targets. Node.js does not add
.jsfor you, and the target path must start with./. - Set TypeScript to a resolver that reads
imports:
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"rootDir": "src",
"outDir": "dist"
}
}
- If you compile from
srctodist, point targets at the emitted files (./dist/lib/logger.js) or use a condition such as"types": "./src/lib/logger.ts"ahead of the runtime target. TypeScript 5.4 and later also mapimportstargets fromoutDirback torootDirautomatically, so a single./dist/...target type-checks against source. - Delete the equivalent
pathsentries once every import has moved, so there is only one alias system to maintain.
Conditions for environment-specific code
The #platform entry above shows the real power of the field: one import, different files per environment. Bundlers targeting browsers match the browser condition; Node.js matches node. Custom conditions work too — run node --conditions=development src/server.js and the #config alias resolves to the development module without code changes. Order matters: conditions are tested top to bottom, so put default last.
Migrating a package from tsconfig paths to imports
Most codebases already have an alias system, usually "@/*": ["./src/*"] in tsconfig.json plus a matching bundler alias and a Jest moduleNameMapper. Moving to imports collapses those three configurations into one, but it is worth doing in a deliberate order so nothing resolves differently halfway through.
- Inventory the aliases in use.
grep -rhoE "from '@/[a-z-]+" src | sort | uniq -cgives you the top-level folders that are actually imported and how often. Each folder becomes one#folder/*key. - Add the
importsentries alongside the existingpaths. Both systems can coexist while you migrate; they do not conflict because#and@/are different prefixes. - Rewrite imports mechanically. A codemod or a careful
sedthat replacesfrom '@/lib/withfrom '#lib/is enough for most projects. Remember to add the.jsextension if your targets require it —importspatterns substitute the*literally, so#lib/loggermaps to./src/lib/loggerunless your pattern appends.js. - Remove the old configuration — the
pathsblock, the bundler alias and the test-runner mapper — oncegrepfinds no remaining@/imports. Running the test suite and a production build after this step proves the runtime now resolves everything throughpackage.json.
The extension point in step 3 deserves emphasis. A pattern such as "#lib/*": "./src/lib/*.js" lets source code write import { log } from '#lib/logger' with no extension while still giving Node.js a concrete file. A pattern without the suffix forces every import to spell out .js. Pick one convention and apply it to every key.
Monorepos and shared aliases
Because resolution is scoped to the nearest package.json, each workspace package declares its own imports. That is a feature: a #config alias in @acme/api cannot accidentally resolve inside @acme/web. It also means you cannot define a repository-wide alias at the root and expect every package to inherit it. If several packages need the same helper, the answer is not a shared alias but a shared package — an internal workspace dependency referenced through the workspace protocol — which gives the code a real name, a version and its own exports map.
Bundled applications are the one place where scoping can surprise you. When Vite or webpack bundles a dependency that uses imports, it resolves those aliases relative to the dependency's own package.json inside node_modules, exactly like Node.js. If you see a bundler resolving a library's #internal alias to your source tree, the bundler is out of date or an alias plugin is intercepting # specifiers globally; remove the plugin rule rather than patching the library.
Edge cases worth knowing before you commit
A handful of behaviours trip up teams in their first week with subpath imports.
Aliases cannot point outside the package. A target such as "#shared": "../shared/index.js" is rejected with ERR_INVALID_PACKAGE_TARGET, because targets must stay inside the package root. That rule is what keeps imports safe to publish, and it is the reason shared code belongs in its own workspace package.
An alias can point at a dependency. A target does not have to be a path; "#fetch": { "node": "undici", "default": "./src/fetch-browser.js" } maps the alias to a bare package name under one condition. Node.js then resolves undici through normal node_modules lookup. This is a tidy way to swap implementations per environment without sprinkling typeof window checks through the code.
Patterns match only one *. Keys may contain a single wildcard, and the matched text is substituted verbatim into the target. You cannot express "any depth, but only .js files", so keep folders shallow or add explicit keys for the few deep paths you need.
Published packages keep working. Because consumers resolve your internal aliases against your package.json, imports is fully supported in published libraries — provided the target files are in the tarball. A missing file produces ERR_MODULE_NOT_FOUND at the consumer's runtime, not at your build, which is why a tarball smoke test belongs in the release job.
TypeScript declaration output keeps the alias. When you emit .d.ts files, TypeScript leaves import('#lib/logger') specifiers untouched. That works for consumers on node16, nodenext and bundler resolution, which read your imports map; on legacy node10 resolution those references fail. If you must support that resolver, bundle declarations with a tool that inlines internal types.
CLI validation and debug commands
# Resolve an alias the way Node.js will
node --input-type=module -e "console.log(import.meta.resolve('#logger'))"
# Check a condition-dependent alias under a custom condition
node --conditions=development --input-type=module -e "console.log(import.meta.resolve('#config'))"
# Confirm TypeScript follows the same mapping
npx tsc --noEmit --traceResolution | grep -A4 "'#logger'"
# Find leftover relative climbs that should become aliases
grep -rnE "from '(\.\./){3,}" src/
import.meta.resolve is evaluated relative to the current working directory for --input-type=module snippets, so run these commands from the package root that declares the aliases.
Prevention and CI/CD guardrails
- Ban deep relative imports with lint. An ESLint
no-restricted-importspattern for../../*pushes new code towards the aliases. - Run the built output in CI, not only the source. A smoke test that starts
node dist/server.jscatches targets that point at source files you do not ship. - Keep aliases private.
importsentries are not part of your public API; never document#specifiers for consumers — expose public paths throughexportsinstead. - Include the aliased files in the
filesallowlist when the package is published, or the runtime lookup fails for consumers.
Frequently Asked Questions
Can other packages import my #aliases?
No. # specifiers resolve only from files inside the package that declares them. A consumer writing import '#logger' resolves against its own package.json, not yours.
Do subpath imports work with CommonJS require()?
Yes. require('#logger') resolves through the same map, using the require and default conditions. The field has been stable since Node.js 14.
Why does Jest fail to resolve #aliases?
Jest 29 and later support imports natively in its resolver. Older setups need a moduleNameMapper entry; upgrading is simpler than maintaining a second alias table.
Related
- Understanding package.json Fields shows how
importsandexportscomplement each other. - Fixing ERR_PACKAGE_PATH_NOT_EXPORTED covers the public-facing counterpart of these resolution rules.
- Sharing a Base tsconfig Across Workspaces sets the
moduleResolutionvalue that makes#aliases type-check. - Fixing ERR_MODULE_NOT_FOUND for Extensionless Imports explains why every target needs an explicit extension.