tsc compiles it. Node throws. aliascheck is the step in between.
You add path aliases, because relative imports six levels deep are miserable:
import { greet } from '@/lib/greet';Your editor is happy. tsc is happy. Then:
$ node dist/index.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@' imported from dist/index.jsTypeScript does not rewrite paths when it emits. That is deliberate — the
team's position is that module resolution belongs to the host, not the compiler
— and it means the alias is copied into the output verbatim. tsc will never
warn you. There is no diagnostic for it, because as far as the type system is
concerned nothing is wrong.
So you find out somewhere else. In a container. In a Lambda. Or from someone who
installed your package, because the same thing happens to your published
dist — and then it is their tsc reporting Cannot find module '@/lib/greet'
against a path that only ever existed on your machine.
There are plenty of fixes: tsc-alias, a bundler, tsconfig-paths, subpath
imports. Every one of them is a remedy you have to already know you need.
aliascheck is the part nobody built: the thing that tells you.
It reads your build output and looks for aliases that are still there. That is
not a heuristic — if dist/index.js contains from '@/lib/greet', that file
throws, and no reasoning about your configuration is required to know it.
$ npm run build && npx aliascheck
3 aliases declared · 1 runtime import (1 type-only, erased by tsc)
~ a TypeScript-aware dev runtime dev only — leaves the output as written
read 3 built file(s) — this verdict is confirmed, not inferred
error alias-in-published-output dist/index.js:1
1 unresolved alias import remains in JavaScript this package publishes, starting with `@/lib/greet`.
so: Anyone who installs this package gets a module that throws ERR_MODULE_NOT_FOUND
on import. It will not fail in this repository, because the alias resolves
here — it fails on their machine.
fix: Rewrite aliases at build time (tsc-alias, or a bundler), or move to
package.json "imports" subpaths.
dist/index.js:1 → @/lib/greet
error alias-in-published-types dist/index.d.ts:1
1 unresolved alias import remains in the declaration files this package publishes.
so: The code runs. What breaks is the consumer's typecheck.If you have not built yet, it falls back to reading your configuration and says so — useful, weaker, and it tells you to build for a definitive answer.
npx aliascheckOr as a dev dependency:
npm install --save-dev aliascheckRequires Node 20.10 or newer. Zero dependencies.
aliascheck # current directory
aliascheck ./packages/api # somewhere else
aliascheck --verbose # list every alias import and how it is classified
aliascheck --json # machine-readable
aliascheck --fail-on warning # stricter gateIn CI, after the build:
- run: npm ci
- run: npm run build
- run: npx aliascheckExit codes: 0 clean, 1 findings at or above the threshold, 2 bad usage.
Type-only imports are not reported. import type { User } from '@/models'
is erased by the compiler and can never fail at runtime. In a codebase that uses
aliases mostly for types, nearly every hit is harmless — and saying so is the
useful answer. Inline markers are handled too: import { type A, b } still
emits, because b is a value.
Declaration files are a separate finding. An alias in dist/index.js throws
ERR_MODULE_NOT_FOUND at runtime. The same alias in dist/index.d.ts runs
perfectly and breaks the consumer's typecheck instead. Same cause, different
symptom, different place to look — so they are reported separately. Bundlers
often rewrite the JavaScript and leave the .d.ts alone, which is exactly how
this one slips through.
A dev runtime is not a fix. tsx, ts-node and tsconfig-paths/register
patch resolution inside the running process. Development works flawlessly and
dist stays exactly as broken as it was. aliascheck marks those ~ rather than
✓, and reports it, because it is the case that costs the most: everything
passes locally right up until someone else installs the package.
| Rule | Severity | Meaning |
|---|---|---|
alias-in-published-output |
error | an alias survives in JavaScript you publish — consumers get ERR_MODULE_NOT_FOUND |
alias-in-published-types |
error | an alias survives in a .d.ts you publish — consumers' typecheck fails |
alias-survives-build |
error | an alias survives in the build output of a private package |
no-rewriter-configured |
warning | aliases will be emitted as written and nothing appears to rewrite them |
dev-only-resolution |
warning | resolution is patched at runtime; the emitted files are untouched |
alias-target-missing |
info | a paths entry points at a directory that no longer exists |
alias-unused |
info | a declared alias nothing imports |
prefer-subpath-imports |
info | the standard-library answer, which needs no tool at all |
Details and worked examples: docs/rules.md.
Node has had a first-class answer since v16, and hardly anyone uses it:
// package.json
"imports": { "#lib/*": "./dist/lib/*.js" }import { greet } from '#lib/greet';Node resolves #-prefixed specifiers itself. No build step, no plugin, no extra
dependency — and, unlike paths, it works for anyone who installs your package,
because the mapping ships in package.json rather than living in a tsconfig
they never see.
aliascheck suggests this, reports nothing when you adopt it, and the bundled
examples/uses-subpath-imports fixture exists to prove it stays silent. A tool
that flagged the correct answer would be worse than no tool.
git clone https://github.com/hamodywe/aliascheck && cd aliascheck
npm install
node src/cli.ts examples/ships-broken # every rule fires
node src/cli.ts examples/uses-subpath-imports # silenceStated plainly.
- Source analysis cannot see types. Without a type checker,
import { Foo } from '@/types'cannot be known to be type-only — TypeScript elides it ifFooturns out to be a type. Explicitimport typeis handled exactly. This is why the built-output check exists, and why the tool is far more useful afternpm run build. - Specifiers must be literal.
import(someVariable)cannot be resolved statically, by this or anything else. filesglobs are treated as prefixes."files": ["dist"]is handled precisely; an exotic glob may misjudge whether a file is published. It errs toward reporting.- Monorepos are scanned one package at a time. Point it at each package, or run it per workspace in CI.
- It does not check that the alias target exists in the output. It reports aliases that were not rewritten, not rewrites that pointed somewhere wrong.
Isn't this just tsc-alias?
tsc-alias is one of the fixes. aliascheck tells you that you need one, and
afterwards confirms it worked — including in the .d.ts files, which several
fixes quietly skip.
Why not just always use a bundler?
Many people do, and then aliascheck reports nothing. It is for the projects that
ship plain tsc output, which is most libraries.
Does it work with jsconfig.json? Yes.
Does it modify anything? No. It only reads.
The most useful issue is a false verdict in either direction — a project flagged that is genuinely fine, or one that is broken and got a clean report. See CONTRIBUTING.md.
MIT © hamodywe
- Module Resolution:
paths— TypeScript handbook, on why emit is unchanged - Subpath imports — Node.js documentation
- tsc-alias · tsconfig-paths — the remedies