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: true uses the TypeScript compiler’s own declaration emitter internally, which does not automatically inherit the esbuild alias rewrite. If your .d.ts files still contain aliases after this change, add a tsc-alias pass specifically for the declaration output directory, or switch to dts: { 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.

TypeScript alias rewrite pipeline Two-stage build pipeline showing TypeScript source with @/ aliases flowing through tsc (which preserves aliases) then tsc-alias (which rewrites them to relative paths) to produce a clean dist folder. src/index.ts @/utils/format tsc emits as-is dist/index.js @/utils/format (broken alias) src/index.ts @/utils/format tsc tsc-alias rewrites .js and .d.ts dist/index.js ./utils/format.js (relative, clean) Without tsc-alias With tsc-alias

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 strict mode and noUncheckedIndexedAccess: These flags do not affect alias rewriting, but they do surface type errors that were previously hidden. Fix type errors with tsc --noEmit before relying on a clean build to validate rewriting.

  • Vite vs Webpack conditionNames: When a consumer uses Vite or Webpack, their bundlers read package.json exports and apply their own conditionNames order. Ensure your conditional exports include "types" first so TypeScript consumers using moduleResolution: "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 TypeScript paths, and do not require tsc-alias. Only compilerOptions.paths aliases in the published dist/ need rewriting.

  • rootDirs and virtual source trees: If you use rootDirs to merge generated code with hand-written source, tsc-alias may 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-alias does 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. Use declarationMap: false in library builds unless you specifically distribute source maps.

  • Circular alias paths: If your paths config contains a cycle (e.g., @/asrc/a which imports @/bsrc/b which re-imports @/a), tsc-alias rewrites all specifiers correctly but the circular dependency itself remains. Use madge --circular to 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.



Up: Path Mapping and Module Resolution Strategies