Ditch Path Aliases: Embrace Native Import Solutions in JavaScript

by Sibbe — 8 minutes

What are path aliases?

Path aliases are a common practice in JavaScript land, and perhaps an even more common source for problems. The idea is pretty simple in module based JavaScript project we use import and require statements to import variables and functions from other files. This often looks like this:

import { myThing } from './my-thing.js';

Sadly, more often than not, we want to import a file from a path far away from the current file, and it ends-up looking more like this:

import { myThing } from '../../../../../some/where/hidden/away/you/find/my-thing.js';
import { myOtherThing } from '../../../../../some/where/hidden/away/you/find/my-other-thing.js'

For many developers this was and is a big frustration, and it is indeed a big downside of having path based imports. So our developer tooling created a solution "path aliases", the practice where we map an alias like my-thing to a path like ../path/to/my-thing.js. A very nice idea, and it made life much easier, but it is still a source of frustration, because each tool has their own configuration, and often their own implementation. So not only do we have to configure it multiple times for different tools, we also have to configure it differently, making it tricky to standardise path aliases across a project.

Going down the rabbit hole

Take Vite and TypeScript for example, both fantastic tools that help significantly with productivity, and both offer the possibility to configure path aliases. In Vite you would have to add the aliases to your vite.config.js:

export default defineConfig({
    resolve: {
        alias: {
            'my-module': './src/path/to/my/module'
        }
    }
})

This would resolve a path like my-module/my-thing.js to ./src/path/to/my/module/my-thing.js, and my-module to ./src/path/to/my/module/index.js. Great right? Well.. If you wanted to have type-safety in your project, you will also need to config TypeScript with the same path alias, and you would have to do that by adding the aliases to your tsconfig.json:

{
    "compilerOptions": {
        "paths": {
            "my-module": ["./src/path/to/my/module/index.js"],
            "my-module/*": ["./src/path/to/my/module/*"]
        }
    }
}

So now we have two places where we have to keep track of our path aliases, and they need to be configured in different ways. Not ideal. Then another tool comes along, and perhaps they don't support path aliases and it all goes down the drain.

Using a monorepo instead of path aliases

These are some of the reasons why path aliases have been a major cause of headaches for me personally, to the point that I would rather setup up monorepos than goes through the hassle of setting up and maintaining path aliases. I would like to add, that this is still my go-to solution, as it brings many more benefits than just simple import paths. For example, it is also a great way to deal with dependency management!

All it requires is splitting your code up into different files and directories, which you've probably already done, and adding a package.json in which ever directory you consider a module. A common pattern is to have a packages directory where you house different packages of reusable logic, and an apps directory where your application specific logic lives.

Which looks something like this:

// apps/web/package.json
{
    "name": "web",
    "type": "module",
    "dependencies": {
        "@my-project/thing": "workspace:*"
    }
}
// packages/thing/package.json
{
    "name": "@my-project/thing",
    "type": "module",
    "exports": {
        "./package.json": "./package.json",
        ".": "./dist/index.js",
        "./*.js": "./dist/*.js"
    }
}

In the web package, you can now import anything exposed by the @my-project/thing package:

import { myThing } from '@my-project/thing';
import { myOtherThing } from '@my-project/thing/other.js';

Using conditional exports you can also export different files depending certain condition being matched. For example, if you wanted to expose TypeScript definitions from a package, you could add it to the "exports" field in the package.json:

{
    "exports": {
        ".": {
            "types": "./dist/index.d.ts",
            "default": "./dist/index.js"
        }
    }
}

Many people however, for whatever reason, dislike monorepos and cling to the ten year old notion that monorepos are complicated, and truthfully, I agree with them to some extend. For simple projects it might feel like a bit of overkill. Monorepos are also not exactly a native solution, they are fully supported in NodeJS, and have been for ages. They are made very simple by tools like PNPM and Turborepo, but without build tools they are no use in the browser, for example. So if you're on the #nobuild side of things, they might not be the right solution for you. Luckily the JavaScript ecosystem is constantly evolving, and improving, and modern JavaScript offers reliable and standardised ways to solve this issue.

Using subpath imports instead of path aliases

Subpath imports are a feature of NodeJS which have been around since v12.19.0, they allow you to define aliases from the safety of your package.json.

{
    "imports": {
        "#my-module": "./src/path/to/my/module/index.js",
        "#my-module/*": "./src/path/to/my/module/*.js"
    }
}

Based on this configuration you can import modules using the #my-module prefix:

import { myThing } from '#my-module';
import { myOtherThing } from '#my-module/other-thing.js';

Subpath imports can also leverage the same powers as conditional exports, making it possible to specify which file or module to import when conditions are met. This is useful for polyfilling modules, and optimising for different runtimes.

{
    "imports": {
        "#my-module": {
            "node": "my-module-from-npm",
            "types": "./src/path/to/my/module/index.d.ts",
            "default": "./src/path/to/my/module/index.js"
        },
    }
}

Subpath imports have been around for a while, and they are supported by most major tooling, such as Bun, Vite and TypeScript. If they are not supported by a specific tool, usually you will be able to find an open issue on Github.

Using importmaps instead of path aliases

While subpath imports are a great alternative to path aliases, they only work in server runtimes such as NodeJS and Bun, or require a build step to leverage them for your browser bundles. Technically they are also not part of the Web API spec, which brings us to the third option: importmaps. Importmaps are in fact part of the Web API spec, and they have been supported by all major browsers for a while now. Deno supports them, and hopefully soon it will be supported by Vite.

<script type="importmap">
    {
        "imports": {
            "my-module": "/path/to/my/module/index.js",
            "my-module/": "/path/to/my/module/"
        }
    }
</script>

Include this snippet with the HTML of your page, and you'll be able to import files as such:

import { myThing } from 'my-module';
import { myOtherThing } from 'my-module/other-thing.js';

This is particularly nice to import modules from CDN to give a NodeJS like experience:

<script type="importmap">
    {
        "imports": {
            "zod": "https://www.unpkg.com/zod@latest/lib/index.mjs"
        }
    }
</script>

Now we can import the zod library in the browser the same as we would in NodeJS or Bun!

import { z } from 'zod';

const UserSchema = z.object({
    name: z.string(),
})

Particularly if you're part of the #nobuild camp, this would be your best bet. Web standard APIs have the highest chance of a bright future, meaning long term support, as it doesn't happen often that Web APIs are removed. Even when an API gets removed or deprecated, browser seem to continue to support them in perpetuity.

Conclusion

We've looked at three ways to replace import aliases with more standardised, and reliable solutions. They all have their strengths and weaknesses, and depending on the scenario one or more might not be an option.

If you don't want to have a build step

use subpath imports on the server (for Bun, and NodeJS), and importmaps in the client or Deno. You might end up having to duplicate a bit of configuration, but it allows you to rely on solutions native to their respective runtimes.

If you don't like monorepos, or they're overkill for your project

Your best bet it to rely on subpath imports, they will work natively on the server and during build-time your bundler and other tools will know how to handle them. With subpath imports you'll be able to configure your aliases in a single

In all other scenario's

Monorepos are the road to victory, these also run natively on the server, and your bundle tool will resolve all paths at build-time. On top of this it will help you structure your project, make your dependency tree more transparent, and with a little bit of thought, it might even safe you from dependency hell. And keep in mind, in monorepos you can still use subpath imports! Got a package that keeps growing in size, and you don't want to split it into smaller packages, use subpath imports and you will be golden.

There are still some very specific use-cases where you might need to configure a path alias in a specific tool, but unless you run into these specific scenarios you would probably do best not to touch them and go with one of the other options.

meerdivotion

Cases

Blogs

Event