Using subpath imports & path aliases

Published by on (last update: )

Subpath imports and TypeScript path aliases are useful and convenient features, especially in large codebases. Both are pretty widely supported across runtimes and bundlers for the web. However, the situation is different in more “vanilla” setups when using the TypeScript compiler (tsc) directly.

This article is about using subpath imports and path aliases with tsc. Specifically, we’re going to discuss two pitfalls when compiling to JavaScript for a runtime like Node.js:

tl/dr; See the recommendations and closing note at the end.

Subpath imports

Subpath imports are configured in package.json, They’re a runtime and dependency-free option to use aliases. Here’s an example import with a hash specifier:

index.js
import { add } from '#utils/calc.js';

Internal subpath imports are configured in package.json like so:

package.json
{
"name": "my-lib",
"version": "1.0.0",
"imports": {
"#utils/*.js": "./lib/utils/*.js",
"#sub/*.js": "./lib/sub/path/*.js"
}
}

Using *.js in subpath imports configuration is essentially the same as **/*.js in glob patterns, so it recurses into subdirectories.

Make sure to check out the Node.js → subpath imports documentation for more features, such as conditional exports.

Problem

Support for subpath imports in package.json has been in TypeScript since v4.5, so tsc compiles them just fine. But the TypeScript Language Server did not fully catch up until v5.4.

Solution (option 1)

Upgrade TypeScript to v5.4.0+ and use only a single subpath "imports" configuration in package.json:

package.json
{
"imports": {
"#utils/*.js": "./dist/utils/*.js",
"#sub/*.js": "./dist/sub/path/*.js"
},
"scripts": {
"build": "tsc"
},
"dependencies": {
"typescript": "5.4.0"
}
}

(Install typescript@beta until latest is 5.4.0 or higher.)

TypeScript will resolve paths properly and prioritize the aliases with auto-import suggestions in your IDE. Here’s an example of how that looks like:

Auto-import suggestion

Pros:

Cons:

If you have path aliases configured in tsconfig.json you’d need to replace them with subpath imports across your codebase.

If this is not an option for you, let’s discuss some alternatives.

TypeScript path aliases

Path aliases are a similar feature to subpath imports. Here’s an example configuration for the TypeScript compiler:

tsconfig.json
{
"compilerOptions": {
"paths": {
"~/utils/*": ["./src/utils/*"],
"~/sub/*": ["./src/sub/path/*"]
}
}
}

Problem

The TypeScript compiler (tsc) does not rewrite import specifiers, so they’re still the same when compiled to JavaScript:

index.js
import { add } from '~/utils/calc.js';

However, this syntax is not supported during runtime in Node.js, resulting in an error:

$ node index.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '~' imported from [...]/index.js

Solution

We have some options here:

  1. Switch to subpath imports with TypeScript v5.4.0+
  2. Build time resolution
  3. Runtime resolution

Option 2: Build time resolution

You can use path aliases and tsc-alias to convert them after the fact to relative paths in the output that tsc generates:

package.json
{
"scripts": {
"build": "tsc && tsc-alias"
},
"dependencies": {
"tsc-alias": "1.8.8",
"typescript": "5.3.3"
}
}
tsconfig.json
{
"compilerOptions": {
"paths": {
"~/*": ["./src/*"],
"~such/path/*": ["./src/much/wow/*"]
}
}
}

Pros:

Cons:

Option 3: Runtime resolution

Other solutions work at runtime. A popular option is tsconfig-paths.

After compilation with tsc you can use a dependency like tsconfig-paths as a loader to convert the import paths during runtime:

node -r tsconfig-paths/register main.js

Pros:

Cons:

Recommendations

1. Relative paths

Your safest bet is to use no subpath imports or path aliases at all.

2. Subpath imports

Second best is to use only subpath imports (option 1), if supported by other tooling in your project such as TypeScript, test runners and code linters. The Node.js and Bun runtimes do support it.

3. Path aliases + build time resolution

And if that’s not an option yet, I’d recommend to use path aliases with build time resolution (option 2). This is fairly well supported across tooling today. There’s no runtime performance hit, and no risk of running the code in an environment that has no support.

Check out the documentation of your tooling to see what’s supported.

Closing Note

Subpath imports are perhaps less well known and less used today compared to TypeScript path aliases, but likely to become even more of a standard in the future. So subpath imports are generally recommended over path aliases going forward, especially considering support in TypeScript v5.4 has fully caught up.

Resources