Reemus Icon

Lerna monorepo with TypeScript incrementalΒ builds

πŸ‘πŸ”₯β€οΈπŸ˜‚πŸ˜’πŸ˜•
views
comments

Looking to build a modular application using TypeScript in an easy to manage format? This article has got you covered.

A monorepo as you are likely aware is a project structure that allows you to easily share packages of code within a project. A basic JavaScript monorepo is fairly easy to get going but using TypeScript requires some additional configuration.

In this article, you will learn how to:

  • Initialize a Lerna repository
  • Create multiple packages that can be imported
  • Setup TypeScript with incremental builds
  • Compile our TypeScript packages

You do not need to be familiar with Lerna to follow this tutorial.

See example GitHub repo

If you would like to see a complete working example, check out the following GitHub repo.

Inspired by

This tutorial was heavily inspired by this Medium post.
However, I ran into a lot of issues following that article and felt that it contained a lot of unnecessary extras.

So I spent some time researching and experimenting with how to create a good TypeScript monorepo with Lerna. This is what I've found.

Install Lerna

Let's start by installing Lerna globally.

npm install -g lerna
shell-icon

Create monorepo folder

Next, we will create our monorepo project folder and navigate to it.

mkdir project
cd project
shell-icon

Initialize Lerna

Inside the project folder, we will initialize our Lerna monorepo. Be sure to read about the two different modes that lerna offers before initializing your repo.

lerna init
npm install
shell-icon

After this, Lerna will have created the following inside your folder:

project
β”œβ”€β”€ packages/
β”œβ”€β”€ lerna.json
└── package.json
txt-icon

As you can expect, the lerna.json file contains the Lerna configuration for this repo. By default, any folders containing a package.json file within the packages folder will be registered as a package with Lerna.

Setup ignore file

I recommend creating a .gitignore file in the root of your monorepo with the following contents:

node_modules
dist
tsconfig.tsbuildinfo
txt-icon

The tsconfig.tsbuildinfo file is used for incremental TypeScript builds and will be explained further in this article. It's safe to delete this file and should not be committed to your VCS.

Install TypeScript

Next, let's install TypeScript in our monorepo. This can be shared across all your dependencies if you are executing commands from the root of your monorepo or using lerna run. It's also useful to do this as our IDE will likely check our root node_modules for the TypeScript package.

To run TypeScript from within an individual package, you will still need to install it into each package's dependencies.

Run the following command in the root of your repo:

npm install --save-dev typescript
shell-icon

Base TypeScript configuration

Once that is done, let's configure our base TypeScript config file. Create a tsconfig.json file in the root of your monorepo. Below is a good base configuration file to work with TypeScript in a monorepo.

{
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": ".",
    "declaration": true,
    "noImplicitAny": true,
    "esModuleInterop": true,
    "module": "commonjs",
    "target": "es6",
    "lib": ["es6"]
  },
  "exclude": ["node_modules", "dist"]
}
json-icon

The important setting to note in our config is composite which is set to true. This setting is required when using project references in TypeScript. We will be using this together with incremental builds to link our dependencies and optimize our builds.

Setup packages

Now let's create two separate packages, an application and logger. Create the following folders and files so your monorepo looks like this:

project
β”œβ”€β”€ packages/app/src/index.ts
β”œβ”€β”€ packages/app/package.json
β”œβ”€β”€ packages/app/tsconfig.json
β”œβ”€β”€ packages/logger/src/index.ts
β”œβ”€β”€ packages/logger/package.json
β”œβ”€β”€ packages/logger/tsconfig.json
β”œβ”€β”€ lerna.json
β”œβ”€β”€ package.json
└── tsconfig.json
txt-icon

Each package contains 3 files, src/index.ts, package.json and tsconfig.json. Copy and paste the relevant code from below into each file.

Logger package

{
  "name": "logger",
  "version": "1.0.0",
  "main": "dist/index",
  "types": "dist/index",
  "files": ["dist"],
  "scripts": {
    "build": "npm run clean && npm run compile",
    "clean": "rm -rf ./dist && rm -rf tsconfig.tsbuildinfo",
    "compile": "tsc -b tsconfig.json"
  },
  "devDependencies": {
    "typescript": "^3.5.3"
  }
}
json-icon
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "exclude": ["node_modules", "dist"]
}
json-icon
export default function logger(message: string) {
  console.log(message);
}
ts-icon

Application package

{
  "name": "app",
  "version": "1.0.0",
  "scripts": {
    "build": "npm run clean && npm run compile",
    "clean": "rm -rf ./dist && rm -rf tsconfig.tsbuildinfo",
    "compile": "tsc -b tsconfig.json"
  },
  "dependencies": {
    "logger": "1.0.0"
  },
  "devDependencies": {
    "typescript": "^3.5.3"
  }
}
json-icon
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [{"path": "../logger"}],
  "exclude": ["node_modules", "dist"]
}
json-icon
import logger from "logger";
 
logger("Application started");
ts-icon

package.json explanation

The package.json file designates the package details. The name provided in this file will determine the name used to import the package. You can also choose to scope all your package names with @project/name.

The main field must point to the commonjs build of our package source to be imported. types specifies the location for this package's type definitions (automatically generated when compiling).

We have not included the main and types field in our application package.json. This is because both main and type are not required if the package you are building is not something that will be imported or published. For example, if it was an application server that will be built and deployed manually, it wouldn't be needed.

The scripts are pretty self-explanatory. The only important thing to highlight is we are invoking TypeScript with -b which uses build mode. This enables incremental builds which can significantly speed up compile time.

To facilitate this, when TypeScript runs in build mode, a tsconfig.tsbuildinfo file is produced. This file can be safely deleted but it will cause TypeScript to rebuild all dependencies. That is why the clean command removes this file to create a fresh build.

You would likely want to use the build command when creating a fresh build for deployment. The compile command can be used in development for rapid builds.

Finally, we must specify our project dependencies. We have included typescript as a dev dependency so we can invoke it easily when within that package folder. For our application, we also included logger as a dependency so we can import it within our code.

tsconfig.json explanation

This is straight forward, we start by extending our base config file in the root of our monorepo.

We only need to override the outDir and rootDir in our options. Make sure to also add exclude again to prevent TypeScript from picking up on our package output and node_modules.

In our application tsconfig.json, you will notice the references setting. This is key to incremental builds and points TypeScript to the location of our logger package.

You will need to add a new reference for every package you import within your monorepo. If this sounds too annoying, I will explain at the end of the article how to remove incremental builds to avoid this step.

Bootstrap dependencies

Before building our application, we must first install our dependencies. To do this in our monorepo, we will use lerna boostrap. This command will:

  • Install all our external package dependencies
  • Create a symlink between local package dependencies inside the relevant node_modules folder
  • Run npm prepare and prepublish inside all local packages

You must run this command instead of npm install or yarn every time you add, remove or modify your dependencies. Let's go ahead and run it.

lerna bootstrap
shell-icon

By default, this command will use npm not yarn. To use yarn instead, see the bootstrap command documentation.

Compile TypeScript

At this point, you are ready to compile your TypeScript. There are 2 ways you can do this. First, you can use lerna run to execute a script inside each package. Or you can navigate into each package and run the build or compile command.

To start let's run the following in the root of our monorepo

lerna run compile
shell-icon

The logger and application packages would have now been compiled from TypeScript. Lerna will make sure to build logger first since application depends on it.

Now let's run it one more time.

lerna run compile
shell-icon

Notice how the second time around, the build time was reduced significantly since TypeScript didn't need to rebuild the packages as no changes were made.

You can learn more about the Lerna run command at the run command documentation.

You can also navigate to an individual package and compile it as follows:

cd packages/app
npm run compile
shell-icon

Extras

IDE saying imported package not found

This seems to happen consistently for me with Webstorm after a clean build where the local package dist folder is deleted. It seems Webstorm is not picking up on the new dist folder being created for some time.

The solution was to restart TypeScript for the IDE. In Webstorm, you can do this by pressing TypeScript x.x.x at the bottom to open the control panel. On the left, press the circular refresh icon to restart TypeScript.

Restart TypeScript in Webstorm

Disabling incremental builds

Don't like the idea of manually referencing packages in each package tsconfig.json or don't want incremental builds?

You can use the default TypeScript build process by doing the following:

  • Remove references from your individual package tsconfig.json
  • Remove composite setting from your monorepo base tsconfig.json
  • Change compile script in package.json to tsc -p tsconfig.json

After that, you will be using the default compile process for TypeScript.

Conclusion

This is all you need as a base TypeScript monorepo. You can expand on it and customize it to your needs. If this can be improved in any way, let me know and I will add it to the article.

If you need more clarity, check out the GitHub repo for this tutorial.

πŸ‘πŸ”₯β€οΈπŸ˜‚πŸ˜’πŸ˜•

Comments

...

Your name will be displayed publicly

Loading comments...