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
onyarn 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:
- Uses React and TypeScript
- Does a server-side rendering (SSR)
- Has a custom node.js server for routing and more
- Includes unit tests
In the future we have to add more features:
- Build a few similar applications
- 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:
- @project/ui - React UI components package
- @project/utils - client/server helpers
- @project/app:
- Uses Next.js framework for SSR
- Uses
@project/ui
dependency - Uses
@project/utils
dependency on server/client
The current structure gives a flexibility to fulfill all requirements we have.
Dependencies
Obviously, besides internal monorepo packages, we use external ones:
- @babel/core:
7.10.3
- babel-jest:
26.1.0
- module-resolver:
4.0.0
- jest:
latest
- lerna:
3.22.1
- next:
latest
- react-dom:
latest
- react:
latest
- tsconfig-paths:
3.9.0
- typescript:
3.9.5
- yarn:
1.22.4
Examples
I prepared some examples we'll consider step by step. First, let's check the core configuration:
- Lerna config
- The root tsconfig.json
- The workspace folder
- yarn workspace
Having yarn workspace configured, we can run yarn install
to generate symlinks in node_modules
.
Let's check it out:
yarn installls -la node_modules/@projectapp -> ../../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';// orimport { 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';// orimport { 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.
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:
- Use built-in webpack aliases support
- Use module-resolver babel plugin (we will get back to this option later in the test configuration)
- Use tsconfig-paths webpack plugin
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 devModule 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#loadersconst 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:
- Use next-transpile-modules
- 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:
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:
- Use ts-jest which reads
tsconfig.json
- 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
andnodemon
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.tsCannot 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:
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:
- tsconfig-paths
- tsconfig-paths + symlinks + project references
- tsconfig-paths + Lerna + symlinks
- module-alias
- webpack + tsconfig-paths-webpack-plugin
- 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
:
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({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
):
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
tsconfig-paths + symlinks + project references
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 intsconfig
. 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:
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:
- Finds @project/utils
- Goes to
node_modules
- Meets symlink which references to
../../packages/utils
- Enters to the
utils
folder - Looks at packages.json
main
field - Goes to
dist/index.js
- Successfully resolves the
logger
module
Drawbacks
- Requires an additional configuration
tsconfig-paths + Lerna + symlinks
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:
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.
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.
Do you like the article or have some remarks? Let's discuss it!
Join the Newsletter
Subscribe to be up to date by email.
Cool content and no spam.