With monorepos, we keep the code for different projects together in one big repository. This makes sharing and reusing the code across multiple projects easier and can simplify dependency management.
Each project has its own directory, scripts, and dependencies. We can use Yarn workspaces designed with monorepos in mind to handle all that. In this article, we explore the principles behind Yarn workspaces and learn how to use them.
If you want to see out the full code from this article, check out this repository.
Introducing Yarn Workspaces
A workspace is a package within a larger multi-package monorepo. Each workspace is a standalone unit that can define its dependencies and scripts.
Yarn is an alternative to NPM. While NPM supports workspaces as well, it was Yarn that introduced them.
First, we need to install Yarn.
1 |
npm install --global yarn |
Now, we must create an appropriate directory structure.
1 2 3 4 5 6 7 |
. ├── apps │ └── articles-manager-api ├── libraries │ └── logger ├── package.json └── tsconfig.json |
In our case, we are creating a very straightforward API to manage articles. We also want to create a simple logger library to log information to the terminal.
We start by creating a package.json file in the workspace root directory.
package.json
1 2 3 4 5 6 7 8 9 10 |
{ "name": "workspaces-typescript", "version": "1.0.0", "license": "MIT", "private": true, "workspaces": [ "apps/*", "libraries/*" ] } |
A few important things are going on above. First, we must mark the package.json in the workspace root as private. Thanks to that, NPM will refuse if we try to publish it by accident.
We also have to point to all our workspaces. We could list them all separately like this:
1 2 3 4 |
"workspaces": [ "apps/articles-manager-api", "libraries/logger" ] |
However, we would have to modify this list whenever we add another workspace to the apps or libraries directories. Thanks to using apps/* and libraries/*, Yarn immediately recognizes all new projects.
Creating a reusable library
We have to create separate package.json files for all workspaces in our monorepo.
1 2 3 4 |
├── libraries │ └── logger │ ├── index.ts │ └── package.json |
While we could also mark the libraries as private, Yarn doesn’t force us to. We could publish individual libraries to NPM if we want to.
libraries/logger/package.json
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "name": "@wanago.io/logger", "version": "1.0.0", "main": "index.ts", "license": "MIT", "dependencies": { "cli-color": "^2.0.4" }, "devDependencies": { "@types/cli-color": "^2.0.6" } } |
What’s important is that it makes sense to define a prefix for all our libraries, such as @wanago.io/. Thanks to that, the names of our libraries won’t collide with packages published to NPM.
We also should define the primary entry point of our library through the main property in our package.json.
Our logger library is straightforward and focuses on logging messages into the console. The color of the notification depends on the severity of the log.
libraries/logger/index.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { bgBlue, bgYellow, bgRed } from 'cli-color'; export function logInfo(message: string) { console.log('INFO:', bgBlue(message)); } export function logWarning(message: string) { console.warn('WARNING:', bgYellow(message)); } export function logError(message: string) { console.error('ERROR:', bgRed(message)); } |
Using the library
Let’s create a simple app that uses our library.
1 2 3 4 5 6 |
├── apps │ └── articles-manager-api │ ├── Article.ts │ ├── index.ts │ ├── isNewArticleValid.ts │ └── package.json |
Since our articles-manager-api application is a workspace, it needs a separate package.json file.
apps/articles-manager-api/package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
{ "name": "articles-manager-api", "version": "1.0.0", "main": "index.ts", "license": "MIT", "dependencies": { "ts-node": "^10.9.2", "typescript": "^5.4.2", "express": "^4.18.3", "@wanago.io/logger": "*" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.11.25" }, "scripts": { "start": "ts-node ./index.ts" } } |
What’s important is that above, we use "@wanago.io/logger": "*". This means that we want articles-manager-api to always use the latest version of the @wanago.io/logger library.
apps/articles-manager-api/index.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import { logInfo, logWarning } from '@wanago.io/logger'; import express from 'express'; import { Article } from './Article'; import { isNewArticleValid } from './isNewArticleValid'; let currentArticleId = 0; const articles: Article[] = []; const app = express(); app.use(express.json()); app.get('/articles', (request, response) => { logInfo('GET /articles'); response.send(articles); }) app.post('/articles', (request, response) => { logInfo('POST /articles'); if (!isNewArticleValid(request.body)) { logWarning(`Invalid article ${JSON.stringify(request.body)}`); return response.sendStatus(400); } const newArticle: Article = { id: ++currentArticleId, title: request.body.title, content: request.body.content } articles.push(newArticle); response.send(newArticle); }) app.listen(3000); |
Above, we create a very simplistic API. If you want to know how to create a fully-fledged API with Node.js, check out the API with NestJS articles series.
Thanks to using it, our articles-manager-api can import the @wanago.io/logger library like any other package.
Installing the dependencies
What’s crucial is that we don’t have to install the dependencies of each workspace directly. Instead, we must go to the root of our monorepo and run yarn install only once. When we do that, Yarn installs the dependencies of all our workspaces at once.
What’s more, Yarn hoists all the dependencies to the top of the monorepo. This means that all of them are located in a single node_modules at the top of our monorepo. It makes the process of installing dependencies more efficient.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
. ├── apps │ └── articles-manager-api │ ├── Article.ts │ ├── index.ts │ ├── isNewArticleValid.ts │ ├── node_modules │ │ └── .bin │ │ ├── ts-node -> ../../../../node_modules/ts-node/dist/bin.js │ │ ├── ... │ └── package.json ├── libraries │ └── logger │ ├── index.ts │ └── package.json ├── node_modules │ ├── @wanago.io │ │ └── logger -> ../../libraries/logger │ ├── express │ ├── cli-color │ ├── ... ├── package.json ├── tsconfig.json └── yarn.lock |
It is important to note that Yarn did not create a copy of the @wanago.io/logger library but created a link and put it into the node_modules at the root of our project. Thanks to that, we don’t have to run yarn install every time we make a change in the library.
It is also worth noticing that Yarn created a small node_modules directory in the articles-manager-api project. It contains the .bin directory with links to executable scripts such as ts-node. Thanks to that, when we run the yarn start command in the articles-manager-api, it can access the dependencies even though the actual packages are hoisted to the root node_modules.
apps/articles-manager-api/package.json
1 2 3 4 |
"scripts": { "start": "ts-node ./index.ts" } ... |
Tools like Lerna
Yarn Workspaces efficiently handle dependencies and link packages. Tools such as Lerna that are designed to work with monorepos support Yarn Workspaces and add more features. For example, Lerna can generate changelog information, publish packages, and automatically increment versions of packages.
Lerna was created before Yarn introduced the Workspaces feature and used a linking mechanism to symlink packages together. However, it’s been a long time since Lerna started supporting Yarn Workspaces instead of doing the heavy lifting itself.
Summary
Monorepos can simplify code sharing and dependency management. In this article, we went through how Yarn Workspaces work and how they use the node_modules directory efficiently. Even if we want to move on to tools like Lerna, transitioning to other solutions is smoother if we have a foundational understanding of Yarn Workspaces. All of the above makes Yarn Workspaces a solid choice that allows us to handle multiple projects within a single repository.