Handling TypeScript Path Aliases in Published Packages
Fix leaked TypeScript path aliases in published npm packages. Rewrite @/ and ~ aliases at build time with tsup, tsc-alias, or rollup-plugin-alias, then validate with publint.
Publishing a package that works perfectly in development only to have consumers hit Error: Cannot find module '@/internal/utils' is one of the most confusing distribution failures in the TypeScript ecosystem. The root cause is always the same: @/, ~, or any other compilerOptions.paths alias survives compilation untouched, and Node.js — which never reads tsconfig.json — cannot map it to a real file.
This page shows exactly which files trigger the error, the minimum reproduction to confirm it, three concrete fix paths, and the validation commands that prove the aliases are gone before you publish.
Root Cause Explanation
TypeScript’s compilerOptions.paths is a type-checking directive, not a code transformer. When tsc emits dist/index.js, it copies the import specifier from your source verbatim. The alias that pointed @/utils at ./src/utils during compilation simply disappears from the build pipeline.
This is covered in detail in the parent Path Mapping and Module Resolution Strategies cluster: the paths field only influences the compiler’s internal resolver, never its emitter. The same gap affects .d.ts declaration files — every import type { X } from '@/types/config' in an emitted declaration is equally unresolvable to a consumer without your source tree.
The --noEmit flag and local development hide the problem because your IDE and ts-node both load tsconfig.json and resolve aliases in memory. The failure only appears in the final published artifact.
Minimal Reproduction
The following three files are the smallest possible setup that triggers the error.
tsconfig.json
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"declaration": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
src/utils/format.ts
export const formatName = (name: string): string => name.trim();
src/index.ts
export { formatName } from '@/utils/format';
After running tsc, inspect dist/index.js:
// dist/index.js — BROKEN: alias survives in emitted output
export { formatName } from '@/utils/format';
And dist/index.d.ts:
// dist/index.d.ts — BROKEN: alias survives in declaration file too
export { formatName } from '@/utils/format';
A consumer running node dist/index.js hits:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@' imported from /path/to/consumer/node_modules/my-lib/dist/index.js
Step-by-Step Fix
There are three approaches depending on your build tool. Pick one.
Option A — tsc + tsc-alias (post-build rewrite)
This is the simplest path if you are already using tsc directly and want no bundler involvement.
Step 1. Install tsc-alias as a dev dependency.
npm install --save-dev tsc-alias
Step 2. Add a build script that runs tsc then tsc-alias in sequence.
Before:
{
"scripts": {
"build": "tsc"
}
}
After:
{
"scripts": {
"build": "tsc && tsc-alias"
}
}
tsc-alias reads the same tsconfig.json paths, scans every .js and .d.ts file in outDir, and rewrites matching specifiers to relative paths. It patches both output types in a single pass.
Step 3. Verify the emitted output is clean.
npm run build && cat dist/index.js
Expected after the fix:
// dist/index.js — CORRECT: alias replaced with relative path
export { formatName } from './utils/format.js';
Option B — tsup with inline alias configuration
If you use tsup to bundle dual ESM and CJS outputs, configure aliases directly in tsup.config.ts so esbuild rewrites them during the bundle step.
Step 1. Create or update tsup.config.ts.
Before (no alias rewriting — broken):
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
});
After (alias rewriting enabled):
import { defineConfig } from 'tsup';
import { resolve } from 'path';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
// esbuild reads paths from tsconfig automatically when this is set
esbuildOptions(options) {
options.alias = {
'@': resolve(__dirname, 'src'),
};
},
});
Step 2. Run the build and inspect both output formats.
npx tsup && cat dist/index.js && cat dist/index.cjs
Both files should reference ./utils/format.js (ESM) or ./utils/format.cjs (CJS), never @/utils/format.
HAZARD PREVENTION: tsup’s
dts: trueuses the TypeScript compiler’s own declaration emitter internally, which does not automatically inherit the esbuild alias rewrite. If your.d.tsfiles still contain aliases after this change, add atsc-aliaspass specifically for the declaration output directory, or switch todts: { resolve: true }which forces tsup to bundle and inline all type references.
Option C — Replace aliases with package.json subpath exports
For packages that expose well-defined internal surfaces, replacing paths aliases with the exports field is the most robust solution. It works without any alias tooling and is natively understood by Node.js 12+, all modern bundlers, and TypeScript when moduleResolution is set to node16 or bundler.
Step 1. Remove paths and baseUrl from tsconfig.json.
Before:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
After:
{
"compilerOptions": {
"moduleResolution": "NodeNext"
}
}
Step 2. Replace each aliased import in source files with a package-relative path.
Before (src/index.ts):
export { formatName } from '@/utils/format';
After:
export { formatName } from './utils/format.js';
Step 3. Add matching subpath exports to package.json if internal surfaces need to be importable by consumers.
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./utils": {
"types": "./dist/utils/format.d.ts",
"import": "./dist/utils/format.js",
"require": "./dist/utils/format.cjs"
}
}
}
Note that condition keys follow blueprint order: types first, default last.
How the alias rewrite pipeline works
The diagram below shows the two-stage pipeline for Option A (tsc + tsc-alias) and how it differs from what a raw tsc invocation produces.
Verification Command
Run this single pipeline after any of the three fix options to confirm no aliases survived into the published artifact:
npm run build && npx publint && npx attw --pack
Expected pass output (all three checks):
✔ publint: No issues found.
✔ @arethetypeswrong/cli: No problems detected.
For a more targeted check that inspects the raw .js and .d.ts files directly:
# Confirm no @/ or ~ strings remain in any dist file
grep -rE '(from|require)\s*['"'"'"](@/|~/)' dist/ && echo "ALIASES FOUND — fix required" || echo "Clean — no leaked aliases"
A clean build prints only Clean — no leaked aliases. If aliases are found, the grep output shows the exact file and line.
You can also smoke-test the built output in isolation, without your source tree in scope:
cd /tmp && mkdir alias-smoke && cd alias-smoke
npm install /path/to/my-lib-1.0.0.tgz
node --input-type=module -e "import { formatName } from 'my-lib'; console.log(formatName(' hello '))"
A working installation prints the trimmed string. An alias leak prints ERR_MODULE_NOT_FOUND.
Edge Cases and Gotchas
-
pnpm hoisting: pnpm’s strict isolation means
@/cannot accidentally resolve via a hoisted package named@. This makes the error more visible with pnpm than with npm, which is useful for catching leaks in CI before they hit consumers using npm. -
TypeScript
strictmode andnoUncheckedIndexedAccess: These flags do not affect alias rewriting, but they do surface type errors that were previously hidden. Fix type errors withtsc --noEmitbefore relying on a clean build to validate rewriting. -
Vite vs Webpack
conditionNames: When a consumer uses Vite or Webpack, their bundlers readpackage.jsonexportsand apply their ownconditionNamesorder. Ensure your conditional exports include"types"first so TypeScript consumers usingmoduleResolution: "bundler"still resolve declarations correctly. -
Monorepo workspaces: In a pnpm or npm workspace, sibling packages often import each other via workspace aliases (
@my-org/shared). These are handled by the package manager, not TypeScriptpaths, and do not requiretsc-alias. OnlycompilerOptions.pathsaliases in the publisheddist/need rewriting. -
rootDirsand virtual source trees: If you userootDirsto merge generated code with hand-written source,tsc-aliasmay not resolve paths that span virtual roots. In this case, prefer Option C (subpath exports) or validate with a fresh install smoke test after every build. -
declarationMap: true: When generating declaration maps (.d.ts.map),tsc-aliasdoes not patch the source paths inside the map file. If consumers navigate to source with “Go to definition”, they may follow a broken path. This is cosmetic for library consumers but can confuse IDE integrations. UsedeclarationMap: falsein library builds unless you specifically distribute source maps. -
Circular alias paths: If your
pathsconfig contains a cycle (e.g.,@/a→src/awhich imports@/b→src/bwhich re-imports@/a),tsc-aliasrewrites all specifiers correctly but the circular dependency itself remains. Usemadge --circularto audit for cycles independently.
FAQ
Why do my path aliases work locally but break after npm publish?
TypeScript’s paths mapping is compile-time only. It tells the type checker where to find source files, but it does not rewrite import specifiers in the emitted JavaScript. Consumers install your dist/ output; Node.js ignores tsconfig.json entirely, so any raw @/ or ~ strings in the published .js files cause immediate module-not-found errors.
Does tsup automatically rewrite path aliases?
Only if you configure it to. tsup uses esbuild internally, which does not read tsconfig.json paths by default. You must add an esbuildOptions alias map in your tsup config, or use tsc-alias as a post-build step after running tsc directly.
Why do my .d.ts declaration files still contain the original aliases after rewriting?
Most bundler alias plugins rewrite .js output but leave .d.ts files untouched. tsc-alias is designed specifically to patch both .js and .d.ts files after tsc emission. If you use tsup’s dts: true, pair it with the esbuildOptions alias configuration so both outputs go through the same rewrite pipeline.
How do I replace path aliases with package.json subpath exports instead?
Map each alias to a subpath export key in package.json. For example, replace @/utils/* with an exports entry like "./utils": { "types": "./dist/utils.d.ts", "import": "./dist/utils.js", "require": "./dist/utils.cjs" }. Consumers then import from my-package/utils, which Node.js resolves natively without any alias tooling. See the parent Path Mapping and Module Resolution Strategies page for the full dual-format exports pattern.
Will publint catch leaked path aliases?
Yes. publint scans the files that would be included in your npm pack and flags import specifiers that start with known alias prefixes (like @/) or that do not resolve to a real path. Running npx publint before every publish is the fastest way to catch leaks before they reach consumers. Pair it with npx attw --pack to also validate TypeScript declaration resolution.
Related
- Path Mapping and Module Resolution Strategies — the parent cluster covering
tsconfig.jsonpathsarchitecture,moduleResolutionmode selection, and CI validation workflows for alias correctness. - Using tsup to Bundle Dual ESM and CJS Outputs — how to configure tsup for dual-format distribution, which is the same build step where alias rewriting must be applied.
- Mastering the
package.jsonexportsField — how to replacepathsaliases with native subpath exports so Node.js, bundlers, and TypeScript all resolve imports without any alias tooling.