How to setup TypeScript path aliases in Lerna monorepo?

Dmytro Chumak
Time to read:
14 min

Sometimes a TypeScript monorepo aliases setup can look like a walk through the minefield. Give me your hand to walk through it together.

TL;DR

In this article, we consider path aliases as an integral part of any monorepo. We skip the configuration details. Instead, we going to focus on discovering the issues TypeScript aliases cause.

Although a monorepo with pure JavaScript packages is a pleasant adventure, things can get worse when we need TypeScript on board.

Knowledge base

Before getting started, make sure that you've got a basic understanding of:

  • Monorepo - git repository that contains more than one logical package/project
  • Lerna - a tool for managing JavaScript projects with multiple packages
  • yarn workspaces - a feature that links monorepo packages by adding symlinks in node_modules on yarn install. As an alternative you can use Lerna bootsrap.

Requirements

Let's assume that in the first milestone we've to build the app that:

  1. Uses React and TypeScript
  2. Does a server-side rendering (SSR)
  3. Has a custom node.js server for routing and more
  4. Includes unit tests

In the future we have to add more features:

  1. Build a few similar applications
  2. Publish React UI components to npm

Basing on the above requirements, I claim that monorepo is a good solution. Let's design it!

Packages

The core element of any monorepo is package. Our monorepo would contain three packages:

The current structure gives a flexibility to fulfill all requirements we have.

Dependencies

Obviously, besides internal monorepo packages, we use external ones:

Examples

I prepared some examples we'll consider step by step. First, let's check the core configuration:

Having yarn workspace configured, we can run yarn install to generate symlinks in node_modules. Let's check it out:

yarn install
ls -la node_modules/@project
app -> ../../packages/app/
ui -> ../../packages/ui/
utils -> ../../packages/utils/

Now that we created symlinks, we can import stuff from packages. For example, to import the Button component from @project/ui, we write:

import Button from '@project/ui/src/Button';
// or
import { Button } from '@project/ui/src';

As you can see, both imports include /src/ path. Traditionally, a lot of Front End npm packages offer imports without the /src/:

import Button from '@project/ui/Button';
// or
import { Button } from '@project/ui';

Technically, we can achieve this by creating a custom build process for each package. In the context of monorepo, this approach has some disadvantages:

  • We need to rebuild a dependency package each time we change something in there
  • A required rebuild causes a lack of Hot Module Replacement. We can accept this for production build, but not for development where we need to have quick access to changes we do.
  • If we want to allow a particular module import (@project/ui/Button), then we get trouble. Why? Imagine that you've dozens of components. To be able to resolve each of them separately, the build process has to place them into package root. It means that the root folder will be polluted. No sense, right?

As a better alternative, we can choose a path aliases solution. It requires a bit more configuration but offers a much better development experience.

Path mapping

Let's start the TypeScript journey. First, we need to define a path mapping:

"paths": {
"@project/ui": ["../ui/src"],
"@project/ui/*": ["../ui/src/*"],
"@project/utils": ["../utils/src"],
"@project/utils/*": ["../utils/src/*"]
}

The above configuration we place to tsconfig.json in the monorepo root folder. Later, we can extend tsconfig of any package. I think this is a beneficial and DRY.

As a result, TypeScript gets the "message":

Hey, TypeScript! Now, when I import a package let me use aliases instead of relative paths. Also, enable a source code navigation and remove import warnings in my IDE.

TypeScript navigation in IDE

In the next step, we examine how to compile client and server code using aliases.

Client path aliases with TypeScript and webpack

We going to use webpack to transpile a client-side code. There are a few options to inform webpack about aliases:

Luckily we use Next.js. The framework has built-in support for absolute imports and module path aliases. In that case, we don't need any extra configuration because Next.js uses tsconfig we already have.

Let's run the development server in @project/app:

yarn dev
Module parse failed: Unexpected token (4:9)
You may need an appropriate loader to handle this file type,
currently no loaders are configured to process this file.
See https://webpack.js.org/concepts#loaders
const Button = () => {
return <button type="button">ui button</button>
}

Whoops! It seems like Button has been resolved properly. That confirms that Next.js aliases support work as promised.

However, webpack doesn't know how to preprocess files from the outside scope (../ui/src/). A webpack loader applied to these files has to solve the problem. We have two options there:

  1. Use next-transpile-modules
  2. Extend the Next.js webpack configuration in next.config.js:
const { join } = require('path');
const workspace = join(__dirname, '..');
module.exports = {
webpack: (config, options) => {
config.module = {
...config.module,
rules: [
...config.module.rules,
{
test: /\.(js|jsx|ts|tsx)$/,
include: [workspace],
exclude: /node_modules/,
use: options.defaultLoaders.babel,
},
],
};
return config;
},
};

In the above example, the Next.js loader preprocesses files from the packages folder (../). Now, when we run yarn dev in @project/app, the development server works as expected:

Client the dev server started successfully

Phew! That isn't so painful, right? In the next section, we'll see how to handle a unit testing topic.

How to make aliases be working with Jest?

Jest doesn't support aliases out of the box. Fortunately, we have some possibilities to make it working:

  1. Use ts-jest which reads tsconfig.json
  2. Use babel-jest together with babel-plugin-module-resolver

Although ts-jest is a straightforward and an obvious solution, the second one requires deep explanation:

In most cases, we use babel-jest to compile JavaScript/TypeScript in Jest. Because of this, it's possible to specify aliases in the babel config file:

{
"presets": ["next/babel"],
"plugins": [
[
"module-resolver",
{
"alias": {
"@project/ui": "../ui/src",
"@project/utils": "../utils/src",
"components": "./components"
}
}
]
]
}

As you can see, the solution is not DRY, because we need to specify path aliases one more time. While we stick with this approach in our examples, you can use ts-jest on your project. The decision is up to you.

Hooray, the second stage passed! Don't relax. The minefield is in front of us :)

Node.js path aliases and TypeScript

Before start, check the server implementation. The server does only a simple routing and logging.

Let's assume this is a way how we use path aliases on server:

import { logger } from '@project/utils';
/**
* We don't care about a bundle size on the server-side.
* In this case, the usage of modular imports is senseless.
* As a result, we can ignore the following import for simplicity:
* import logger from '@project/utils/logger';
*/

Although there is no usage difference between server and client-side, there are configuration differences.

Usually, using TypeScript for server app development implies:

  • Usage of ts-node and nodemon in development
  • Compiling TypeScript code to JavaScript and running a Node.js server for production

If we run ts-node we get the error:

npx ts-node --project tsconfig.server.json server/index.ts
Cannot find module '@project/utils'

Ouch! That's not cool. But I have an idea of how to find the reason of the issue. Let's try to compile a TypeScript code using tsc. I promise you will understand in a moment why we do this. To get a production server build we have to execute the build command:

TypeScript production build output

Analyzing the dist folder output and line 9 of index.js we can claim about:

  • TypeScript CAN resolve and compile dependencies properly based on path mapping we defined earlier
  • TypeScript CANNOT replace "@project/utils" with "../utils"

The last point is the root of all evil. That's why we get an error running ts-node. The same happens when we run the compiled production code using Node.js.

I hope it gets clear now. Let's deep into some details.

Doesn't TypeScript support aliases?

There are no plans to change the above behavior ( roadmap, issue 1, issue 2, issue 3 ). As a result, TypeScript has no built-in support for aliases.

The simplest solution is good old relative paths:

import { logger } from '../../utils';

If it doesn't make you happy, read further.

How to make aliases work in Node.js runtime with the TypeScript app?

In this part, we going to learn how to:

  • Make aliases work with ts-node development
  • Run compiled production code using Node.js

There are a lot of different workarounds on how to solve the problem. However, we consider only marked ones:

  1. tsconfig-paths
  2. tsconfig-paths + symlinks + project references
  3. tsconfig-paths + Lerna + symlinks
  4. module-alias
  5. webpack + tsconfig-paths-webpack-plugin
  6. ttypescript + @zerollup/ts-transform-paths
tsconfig-paths

This solution relies on using tsconfig-paths in development and production. The package does the simple thing:

... loads modules whose location is specified in the paths section of tsconfig.json

Development

To make it working in development we have to preload tsconfig-paths at ts-node startup:

ts-node --project tsconfig.server.json -r tsconfig-paths/register server/index.ts

You can expect the following behavior with and without -r tsconfig-paths/register:

tsconfig-paths development start

Production

To ensure the best performance on production, the ts-node usage has to be avoided. Instead, we've to compile TypeScript code to JavaScript and run the server using Node.js. It means that tsconfig-paths has to be preloaded at node startup. But how to include tsconfig if node doesn't accept the --project option?

Let's create a tsconfig-paths-prod.js file:

const tsConfigPaths = require('tsconfig-paths');
const { compilerOptions } = require('../../tsconfig.json');
tsConfigPaths.register({
// highlight-next-line
baseUrl: 'dist/app',
paths: compilerOptions.paths,
});

As you can see, baseUrl differs from the one in tsconfig.json. The reason is a different output destination of the production server (dist/app):

TypeScript production build output

Now, we have to preload tsconfig-paths-prod.js before Node.js startup.

"start": "cross-env NODE_ENV=production node -r ./tsconfig-paths-prod.js dist/app/server/index.js"
Drawbacks
  • Is it safe to include tsconfig-paths in production? Honestly, I am not confident (details).
  • Production requires an additional configuration

Test it out!

TypeScript has a wonderful feature called "project references". Using the feature we can inform TypeScript:

Hey typescript, when I call you to compile a project, check the references field in tsconfig. Based on this, build the project and packages it refers to.

Development

Use tsconfig-paths.

Production

In our case, @project/app package has a single reference - @project/utils. To set it up, extend tsconfig.json:

"references": [
{
"path": "../utils"
}
]

The next step is to add tsconfig.json to utils package:

{
"extends": "../../tsconfig",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"baseUrl": ".",
"composite": true, // ensure TS can find the outputs of the referenced project
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
}
}

Also, it needs to adjust the build command in @packages/app:

"build": "next build && tsc --build ./tsconfig.server.json",

The purpose of a --build flag is:

Builds a project and all of its dependencies specified by project references.

In our case, the build produces dist folders in @packages/app and @packages/utils packages:

Project references production build

Turn attention to @packages/app dist folder. A project references usage cause the utils folder excludes from dist. That's nice because now we get only @packages/app files.

Now, we have to specify an entry point of compiled files in @packages/utils (packages.json):

"main": "dist/index.js",
"types": "dist/index.d.js",

This solution allows us to import module like this:

import { logger } from '@project/utils';
Path resolving

When we run the production server, node.js do the following to resolve files:

  1. Finds @project/utils
  2. Goes to node_modules
  3. Meets symlink which references to ../../packages/utils
  4. Enters to the utils folder
  5. Looks at packages.json main field
  6. Goes to dist/index.js
  7. Successfully resolves the logger module
Drawbacks
  • Requires an additional configuration

Test it out!

Development

Use tsconfig-paths.

Production

Lerna has a lerna run command. It runs npm script in each package that contains it. Because of this, the idea is to specify its build process for each package. Also, in the monorepo root, we can add the Lerna script that builds all packages we need.

First, let's create the Lerna script that builds @package/app and @package/utils. The good naming for this could be app:build. Now, look what happens when we run it:

  • Lerna evaluates an execution order based on each package packages.json dependencies
  • Lerna executes @package/utils build
  • Lerna executes @package/app build

As a result, we get the output:

Lerna production build

Don't forget to specify a compiled files entry point in @packages/utils packages.json:

"main": "dist/index.js",
"types": "dist/index.d.js",

To run the production server, we need to add and execute the following script:

"start": "cross-env NODE_ENV=production node dist/app/server/index.js",
Path resolving

As here.

Drawbacks
  • Impossible to build all project dependencies from its context. To build and start we have to navigate from monorepo root folder to the project.

Test it out!

Conclusions

In my opinion, it's a good idea to use path aliases in monorepo. It saves us from refactoring when we move files, change the structure, etc.

On the other hand, a aliases configuration process can be complex and time-consuming. In fact, to set up aliases in the real world project we need:

  • A custom webpack configuration if our framework doesn't support aliases out of the box
  • A separate configuration for Jest
  • To get around TypeScript compilation issue
  • To add some special rules we want to use eslint

Unfortunately, there is no silver bullet that can handle all these cases.

I will be grateful if you share the article:

Join the Newsletter

Subscribe to be up to date by email.
Cool content and no spam.