typescript: absolute imports and aliases

2021-04-14

 | 

~4 min read

 | 

624 words

Update May 22, 2021: I’ve now tried this a number of times since writing this originally and nearly every time I run into problems where it doesn’t work as expected. Despite reading the documentation and feeling pretty good about following the patterns / updating both the baseUrl and the paths as documented in the TS handbook. It appears that this is not the intended use of this feature and Typescript doesn’t officially support it as of March 2020 at least (source).
I’ve spent enough time working through different examples. I may return to it at a later date, but I’m moving on from the problem for now.
If you want to look at the problem, I have a CodeSandbox that I feel should work… but isn’t. If you see the problem, please ping me!

Update November 29, 2021: The trailing /* on the key and value of the paths is significant. Without it, Typescript will only resolve to that directory, which is unlikely the desired behavior. I have updated the CodeSandbox to show two separate solutions.

Quality of life enhancement for typescript projects is to use absolute imports.

Let’s walk through how we can go from:

relativeImports.tsx
import { Button } from "../../../Button"
import { utilFn } from "../../../utils/specialUtils"

to:

absoluteImports.tsx
import { Button } from "components/Button"
import { utilFn } from "utils/specialUtils"

Imagining for the time being that the directory structure of the project looks a bit like:

.
├── src
│   ├── components
│   └── utils
└── test

There are actually two updates that we’ll want to make:

  1. Update the paths in the compilerOptions to point to these directories so that they can be treated as modules.
  2. Update the include option to ensure that they’re actually included in the compiled output.

Note: If the project is already using typescript, we can make the change in the tsconfig.json. If not, we can use an jsconfig.json.

The changes might look like the following

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src", //relative to where the tsconfig is
    "paths": {
      "*": ["./*"],
      "test/*": ["../test/*"],
      "components/*": ["src/components/*"],
      "utils/*": ["src/utils/*"]
    }
  }
}

With this configuration, we’ll map all of the modules as top level relative to src. Note that test has to also be mapped relative to the baseUrl, which was made src. I’ve seen many configurations where the root of the project directory (where the tsconfig lives) is used as the baseUrl, in practice, I’ve always had a hard time with getting that to work.

This enables a much better developer experience. Not only are modules resolved as top level entities, but the developer can “click through” to see the source code as well. It also allows modules to be namespaced, for example:

absoluteImports.tsx
import { Button } from "Button"
import { utilFn } from "specialUtils"

Though, because we also called out the components/* and utils/*, we can still do what we set out to do:

absoluteImports.tsx
import { Button } from "components/Button"
import { utilFn } from "utils/specialUtils"

Because it can sometimes be challenging to understand what’s a “local” module vs a true imported module, two common practices I’ve observed are:

  1. prefix local modules with an @ or a #
  2. prefix local modules with a short name of the app/library.

For example

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src", //relative to where the tsconfig is
    "paths": {
      "internal/*": ["./*"],
      "@test/*": ["../test/*"],
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}

And that should be all that’s necessary so that when Typescript compiles the code, it will resolve the import statements accordingly and improve the developer experience along the way.

Additional reading / resources:


Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!