Typescript Monorepo with NPM workspaces

·

During the development of my recent project (more info on my website), I wanted to make it more modular. It consists of 3 main components: api which is the API server; a process that is responsible for RSS feed discovery called observer; and a process responsible for sending out emails called distributor. They all depend on a database.

I wanted all of them be independent executables. Using a monorepo for such use case, is great. But settings up dependencies between them, is complicated.

Luckily, npm 7 has support for workspaces, which simplify the management of monorepos. Not only that, but using workspaces also helps you to keep your node_modules more lean (as much as possible).

How it works

Let’s say you have 3 projects:

  • infra which responsible for working with the database
  • api which is your API server
  • worker which is some kind of asynchronous processing worker

You want to keep them all separated, with their own set of dependencies. Both api and worker need to depend on infra. You could just create 3 different projects, publish the infra on some internal repository, and install it as a dependency for api and worker. But this really complicates things.

Instead, you can setup a workspace using npm (we will see in a moment how to do that). On top of all that, npm will optimize your node_modules. So if both api and worker depend on the same version of uuid package, instead of installing the package two times, it will be installed only once.

The general project structure also looks different:

.
├── node_modules
├── package-lock.json
├── package.json
├── packages
│   ├── api
│   ├── worker
│   └── infra
├── tsconfig.build.json
└── tsconfig.json

You get only one node_modules at the top level. All dependencies will be there, including symlinks to your internal packages:

.node_modules/
├── ...
├── api -> ../packages/api
├── worker -> ../packages/worker
└── infra -> ../packages/infra

So this means you can install them just like regular packages! Let’s dive in.

Setting up npm workspaces

We are also going to setup typescript along the way, as there are some caveats.

Inside your top level folder, you will need to create a package.json (either manually, or via npm init):

{
  "name": "my-app",
  "private": true,
  "scripts": {},
  "workspaces": ["packages/*"],
  "devDependencies": {
    "@tsconfig/recommended": "^1.0.2",
    "@types/node": "^20.6.0",
    "ts-node": "^10.9.1",
    "typescript": "^5.2.2"
  }
}

The most important line is "workspaces": ["packages/*"], which instructs npm to treat this package as a workspaces root. All the actual packages will be inside the ./packages folder. By the way, you can name this folder whatever you want. I also added some dev dependencies for typescript.

While we are inside the root folder, it’s also a good time to create some typescript boilerplate.

First, we need a base tsconfig.json.

{
  "extends": "@tsconfig/recommended",
  "compilerOptions": {
    "incremental": true,
    "target": "es2019",
    "module": "commonjs",
    "declaration": true,
    "sourceMap": true,
    "composite": true,
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

This is the base typescript configuration for all the packages. Since we want to keep everything consistent throughout the monorepo, we will change any project wide settings there. Next, we need another config, this time for building the entire monorepo: tsconfig.build.json:

{
  "files": [],
  "references": [
    {
      "path": "packages/infra"
    },
    {
      "path": "packages/api"
    },
    {
      "path": "packages/worker"
    }
  ]
}

And while we are here, let’s also add a build script to the root package.json:

...
"scripts": {
    "build": "tsc --build --verbose tsconfig.build.json",
}
...

This way, when we run npm run build in the root of the monorepo, the entire app will be built.

Creating a package

Now, we need to create individual packages. This can be done either manually, or via npm init. For example, in order to create the infra package, we will execute npm init --workspace packages/infra -y. This will create a default package.json inside the packages/infra directory.

Here, we work regularly. We install dependencies as if it was a standalone package. Any npm script can be executed from inside the packages/infra directory, or from the root directory using npm run <script-name> --workspace packages/infra.

Another tip. If you want to execute a script across all packages, you can also use npm run <script-name> --workspaces. This will iterate over all the packages in a workspace, and execute the said script (or emit error if no such script is defined). This is very handy for running tests, for example.

In order to install a dependency inside a package, we will execute npm install --save uuid --workspace/infra. However, as you will notice, there won’t be a node_modules inside packages/infra. Instead, all the dependencies will be put in a top level node_modules.

Finally, let’s answer the question of how to share infra with both api and worker. Repeat the above steps in order to create packages/api and packages/worker.

And now, when you have all of them ready, just execute npm install --save @mycompany/infra --workspace packages/api (and repeat it for worker as well).

Setting up typescript

Lastly, we want to set up typescript for the individual packages. Inside every package, create a tsconfig.json that looks like this:

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "paths": {
      "@mycompany/*": ["./packages/*"]
    }
  },
  "references": [
    {
      "path": "../infra"
    }
  ]
}

First, we extend the global tsconfig.json that we created. This is necessary in order to keep the general guidelines among all packages—the same.

Next, we override some compilerOptions. It’s needed in order to tell typescript where are the source files and where is the output directory. We can’t include it in the base config, since it’s relative to the package path.

On the api and worker packages, we need two more things: paths and references.

paths allows us to use scoped imports such as import x from @mycompany/infra.

references allows us to use the .d.ts files of the dependent package. If you want to learn more about references, consider reading the official documentation.

Now, when we execute npm run build, all the packages will be built. According to our internal tsconfig.json files, the output will be placed inside packages/<package-name>/dist.

Caveats

While workspaces in npm are great to keep things clean, they come with some caveats.

One problem I’ve read about online, but haven’t encountered myself, is improper dependency resolution. Since all dependencies are flattened and put into a global node_modules directory, some people reported that it can cause bugs when the wrong dependency is imported.

Another downside of this approach, is that you won’t be able to produce atomic units for deployment. Since node_modules is shared among all packages, it needs to be included with each and every executable unit.

Consider a scenario where api depends on a lot of packages, but worker depends only on @mycompany/infra (which in turn depends on some database package). In order to deploy worker independently, you will have to copy the entire node_modules along with it. This is less than ideal if you deploy api and worker on separate docker containers or machines. However, if you have one Docker container that runs both using supervisord or pm2—it’s not a problem.

Share this:

Published by

Dmitry Kudryavtsev

Dmitry Kudryavtsev

Senior Software Engineer / Tech Entrepreneur

With more than 14 years of professional experience in tech, Dmitry is a generalist software engineer with a strong passion to writing code and writing about code.


Technical Writing for Software Engineers - Book Cover

Recently, I released a new book called Technical Writing for Software Engineers - A Handbook. It’s a short handbook about how to improve your technical writing.

The book contains my experience and mistakes I made, together with examples of different technical documents you will have to write during your career. If you believe it might help you, consider purchasing it to support my work and this blog.

Get it on Gumroad or Leanpub


From Applicant to Employee - Book Cover

Were you affected by the recent lay-offs in tech? Are you looking for a new workplace? Do you want to get into tech?

Consider getting my and my wife’s recent book From Applicant to Employee - Your blueprint for landing a job in tech. It contains our combined knowledge on the interviewing process in small, and big tech companies. Together with tips and tricks on how to prepare for your interview, befriend your recruiter, and find a good match between you and potential employer.

Get it on Gumroad or LeanPub