I try to help

Monorepo Setup - Typescript, Lerna, and Yarn Workspaces

Monorepo

Approximate reading time: 9 minute(s)

UPDATE: I have since tried Turborepo and Nx. And both of those are probably better than this solution.

The information in this article is still valid, but probably not the best advice I can give.

This article is the first in a three-part series about monorepos.

We'll be looking at setting up a monorepo and use the following tools: Typescript, Lerna, Yarn Workspaces, Webpack, Nodemon. If my choices are not to your liking I've linked some articles in the notes section of this article.

If you don't want to read and want to skip stright to the code you can find the repository here.

What are we building?

We're going to be creating a monorepo for a fake dice roll application. So we'll have the following packages:

  • diceroll - main logic
  • api
  • ui

Workspaces & Lerna

We will be using Lerna and Yarn Workspaces to help in creating, bootstrapping and running commands across our packages. As usual, it's a good idea to read the documentation. If a concept isn't explained here (and it probably isn't) the documentation should help.

The first thing we'll do is create a package.json for our monorepo

// package.json
{
  "name": "monorepo-template",
  "version": "1.0.0",
  "private": true,
  "workspaces": ["packages/*"]
}

Install Lerna using

yarn add lerna -D -W

Create a lerna.json

// lerna.json
{
  "packages": ["packages/**"],
  "version": "independent",
  "npmClient": "yarn",
  "useWorkspaces": true
}
yarn add typescript -D -W
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",

    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,

    "strict": true,

    "skipLibCheck": true,

    "esModuleInterop": true,
    "module": "commonjs",
    "moduleResolution": "node",
    "target": "es2015",

    "composite": true
  }
}

These will serve as a base configuration for our packages so we don't have to repeat ourselves too much. Feel free to adjust this to your preferences. I've opted to use Project References to improve build times. You can chose to not use it if you so desire.

So far our project looks like this:

monorepo/
├─ lerna.json
├─ package.json
├─ tsconfig.json

Create Packages

Now it's time to create our three packages. First we'll create diceroll and ui packages.

Our goal is to end up with something that looks like:

monorepo/
├─ packages/
│  ├─ diceroll/
│  │  ├─ src/
│  │  │  ├─ index.ts
│  │  ├─ package.json
│  │  ├─ tsconfig.json
│  ├─ ui/
│  │  ├─ src/
│  │  │  ├─ index.ts
│  │  ├─ package.json
│  │  ├─ tsconfig.json
├─ lerna.json
├─ package.json
├─ tsconfig.json

Diceroll

The diceroll package contains the core business logic and is going to not have any internal dependencies (actually any dependencies whatsoever). The following files do not contain anything of interest and are here to pad the reading time.

// packages/diceroll/package.json
{
  "name": "@monorepo/diceroll",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
  "private": true,
  "scripts": {
    "build": "tsc -b"
  }
}
// packages/diceroll/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "exclude": [
    "node_modules",
    "dist"
  ]
}
// packages/diceroll/src/index.ts
export function roll(roll: string): string {
  return `I rolled a dice: ${roll}. Outcome grim`;
}

UI

First off, let's test out our wiring so let's keep this simple.

// packages/ui/package.json
{
  "name": "@monorepo/ui",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "tsc -b"
  },
  "dependencies": {
    "@monorepo/diceroll": "^1.0.0"
  }
}

We specify ES2019 as our target, feel free to change this to whatever you desire and add the dom definitions. Do note the references section. This tells Typescript that we will be referencing those files and when those change we will want to check/compile our code. This means we catch breaking changes fast.

// packages/ui/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "target": "ES2019",
    "lib": [ "dom", "ES2019"]
  },
  "references": [
    { "path":  "../diceroll" }
  ],
  "exclude": [
    "node_modules",
    "dist"
  ]
}
// packages/ui/index.ts
import { roll } from '@monorepo/diceroll';

console.log(roll('1d20'));

Now I'm sure that as soon as you typed this into a file, your IDE started yelling at you that the package doesn't exist. It's correct. It doesn't yet exist! We have yet to build it.

Let's first ensure our dependencies are installed and linked by running:

lerna bootstrap

Now, let's build everything in one go:

lerna run build --stream

You'll notice a new file called tsconfig.buildinfo in each package folder. This is where Typescript stores some information about the compile process for each package. You may have seen this before if you used the incremental option.

Great, now let's add a clean script to all our packages and one in our root package.json.

// packages/diceroll/package.json
   "scripts": {
     "build": "tsc -b",
+    "clean": "rm -rf ./dist && rm tsconfig.tsbuildinfo",
   },
   "devDependencies": {
// package.json
   "files": [
     "dist"
   ],
+  "scripts": {
+    "build": "lerna run build --stream",
+    "clean": "lerna run clean --parallel"
+  },
   "devDependencies": {
     "lerna": "^3.22.1",
     "typescript": "^4.1.3"

Watch mode

What's a good build system if it doesn't have watch mode? Let's add it to the packages:

// packages/diceroll/package.json
   "scripts": {
     "build": "tsc -b",
     "clean": "rm -rf ./dist && rm tsconfig.tsbuildinfo",
+    "watch": "tsc -b -w --preserveWatchOutput"
   },
   "devDependencies": {

So let's take a step back and see what we have achieved thus far:

  • We can have an arbitrary number of packages which work together
  • Typescript compiles as fast as it can and we have a watch mode too
  • We have a cleanup script should we ever need it

But our UI doesn't work, it's not a UI!

UI - for real!

Let's add Webpack. We have two choices. Either we run:

yarn add --dev html-webpack-plugin webpack webpack-cli webpack-dev-server

or

lerna add --scope=@monorepo/ui -D webpack
lerna add --scope=@monorepo/ui -D webpack-cli
lerna add --scope=@monorepo/ui -D webpack-dev-server
lerna add --scope=@monorepo/ui -D html-webpack-plugin

Now let's scaffold some files:

// packages/ui/src/index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta http-equiv="X-UA-Compatible" content="ie=edge" />

  <title>DiceRoll</title>
</head>

<body>
  <div id="result"></div>
</body>

</html>
// packages/ui/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = function(env, argv) {
  return {
    mode: env.production ? 'production' : 'development',
    devtool: env.production ? 'source-map' : 'eval',

    devServer: {
      open: true,
      historyApiFallback: true
    },

    entry: {
      index: './build/index.js',
    },

    plugins: [
      new HtmlWebpackPlugin({
        template: './src/index.html',
      }),
    ]
  }
};

If you were paying attention and not blindly copying you would have noticed the build folder. Follow on.

// packages/ui/tsconfig.json
   "extends": "../../tsconfig.json",
   "compilerOptions": {
-    "outDir": "dist",
+    "outDir": "build",
     "rootDir": "src",
// Omitted for brevity
   "exclude": [
     "node_modules",
+    "build",
     "dist"
   ]

Why are we doing this? It's because our final build artifacts bundled by Webpack not Typescript. We'll have to update our scripts to reflect this.

// packages/ui/package.json
   "scripts": {
-    "build": "tsc -b",
-    "clean": "rm -rf ./dist && rm tsconfig.tsbuildinfo",
-    "watch": "tsc -b -w --preserveWatchOutput"
+    "start": "webpack serve",
+    "build": "yarn compile && webpack --env production",
+    "clean": "rm -rf ./dist && rm -rf ./build && rm tsconfig.tsbuildinfo",
+    "watch": "tsc -b -w --preserveWatchOutput",
+    "compile": "tsc -b"
   },

This is where I'm going to do the first unorthodox thing: I won't use Babel or a typescript loader such as awesome-typescript-loader or ts-loader.This is so we have a better understanding of the role each tool has. Feel free to add these if you want them.

API

Ok great, we have a UI, but we also want an API! Let's get cracking!

For our API we'll use Nodemon to restart out application. We won't use ts-node. In my experience, ts-node is slow so I've decided to skip it this time.

Let's start by copying package.json and tsconfig.json from the diceroller folder and applying the following modifications.

// packages/api/package.json
   "private": true,
   "scripts": {
     "build": "tsc -b",
+    "start": "nodemon --inspect dist/index.js",
     "clean": "rm -rf ./dist && rm tsconfig.tsbuildinfo",
     "watch": "tsc -b -w --preserveWatchOutput"
   },
// packages/api/tsconfig.json
     "outDir": "dist",
     "rootDir": "src",
+
+    "module": "commonjs",
+    "target": "ES2019",
+    "lib": [ "ES2019" ]
   },
+  "references": [
+    { "path":  "../diceroll" }
+  ],

Let's add our packages

lerna add --scope=@monorepo/api express
lerna add --scope=@monorepo/api -D nodemon
lerna add --scope=@monorepo/api -D @types/express

How do we start our api? This is where some people will groan. You have to run two commands: watch and start. You can add a helper like concurrently if you so desire. But for the purposes of this example we will stick to running two commands manually.

Closing statements

We have created a monorepo using the minimum amount of tools. We have a stable, working base that provides a good developer experience. In part two of this series will focus on adding Jest, ESLint and more to our monorepo. Stay tuned!