tsconfig paths aliases fail at runtime after emit despite passing type-check
0 reputation · 22 Feb 2025, 19:22 UTC
0 reputation · 22 Feb 2025, 19:22 UTC
TypeScript path mappings declared via baseUrl and paths in tsconfig.json are compile-time module-resolution aliases. Local type-checking and development tools can resolve them, but emitted JavaScript typically retains the original import specifiers unchanged, creating a gap between local success and production runtime behavior.
The unresolved decision is that TypeScript provides no built-in compiler option to rewrite these aliases into relative or absolute paths during emit. Whether aliases work after compilation depends entirely on the production runtime or bundler configuration, such as webpack, Vite, esbuild, or Node.js. Behavior can also diverge under moduleResolution node16 or nodenext because package.json exports fields influence resolution independently of paths. Because resolution rules vary across TypeScript versions, any diagnosis should account for the exact compiler version and module settings in both environments.
Which combinations of module and moduleResolution are expected to preserve alias behavior through emit for Node.js ESM? How can teams confirm that a production bundler mirrors tsconfig paths without maintaining a separate alias list? Is there a documented, stable transform that relocates paths aliases at emit time?
35275 reputation · 23 Feb 2025, 06:50 UTC
TypeScript's baseUrl and paths settings are strictly for the compiler's type-checking and module-resolution phases. The TypeScript compiler (tsc) does not rewrite these aliases into relative paths during the emit process. Consequently, if you import @app/utils, the emitted JavaScript file will still contain require('@app/utils') or import ... from '@app/utils', which Node.js cannot resolve natively.
For Node.js ESM, no combination of module and moduleResolution (including node16 or nodenext) will cause tsc to transform aliases into relative paths. These settings change how TypeScript finds the files for type-checking, but they do not change the output string of the import specifier.
To achieve alias-like behavior in native Node.js ESM without a bundler, you must use the imports field in package.json. This is a native Node.js feature that mirrors tsconfig paths:
// package.json
{
"imports": {
"#app/*": "./dist/app/*"
}
}
Note: Native Node.js imports must start with a hash (#) to distinguish them from scoped packages.
To ensure a production bundler mirrors tsconfig paths without maintaining a duplicate list, use a plugin that reads the tsconfig.json file directly during the build process:
vite-tsconfig-paths.tsconfig-paths-webpack-plugin.esbuild-plugin-tsconfig-paths.These tools intercept the resolution process and apply the tsconfig mappings at the bundling stage, eliminating configuration drift.
There is no stable, built-in tsc option to relocate paths. For projects that cannot use a bundler or package.json imports, the industry standard is to use a post-emit transform tool such as tsc-alias. This tool scans the emitted JavaScript files and replaces the aliases with actual relative paths based on the tsconfig mapping.
To confirm where the resolution is failing, perform these checks:
tsc and open a generated .js file. If the alias (e.g., @app/...) is still present, the compiler is behaving as expected (not transforming).node -r tsconfig-paths/register dist/index.js. If it works now, the issue is strictly the lack of runtime path resolution.Diagnostic Detail Needed: Are you targeting a native Node.js runtime (no bundler) or using a build tool like Vite/Webpack?
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.