Sitemap
ITNEXT

ITNEXT is a platform for IT developers & software engineers to share knowledge, connect, collaborate, learn and experience next-gen technologies.

Easy Typescript Monorepo with Nx and NPM Workspaces

A guide on how to create a typescript monorepo using npm workspaces and Nx

11 min readJul 17, 2023

--

Press enter or click to view image in full size
Cover image for article about easily creating a typescript monorepo using npm workspaces and nx
Create a typescript monorepo using npm workspaces and nx

This article explains how you can have multiple typescript projects in a monorepo. I’m writing this article because I’m sick of simple examples like an even or odd package that don’t come close to representing a true use case.

You can find the finished implementation of what this article talks about here:

What is a Monorepo

A monorepo is a collection of relevant code grouped together. Generally, it includes multiple applications and libraries which the applications depend on. A monorepo is tracked in one repository, hence the name.

There are many reasons to use a monorepo, including making it easier to run tests, build operations, and create libraries to make code re-useable across multiple applications, and one of my personal favorites, getting the frontend and backend teams to stop acting like they’re on two different planets.

What are we doing here?

We’re setting up a monorepo with 3 typescript packages, two are applications and one is a library imported by both of these applications.

We’ll set the whole thing up in typescript, and we’ll make it so that when we make a change in the imported library, it automatically triggers a re-build of the applications which import it.

An Example Problem

Let’s say we have a basic old fashioned application with a frontend and a backend, and that they both depend on the same library. That could be something used for validation, common domain types, or anything at all.

If the backend and the frontend were in separate repositories, we would have to either duplicate the code to both of them which is something everyone and their grandmother knows not to do at this point, or we would have to export the package to npm and add it to both the backend and the frontend as a dependency, or something along those lines.

That sounds a bit involved and maybe you don’t want to publish your package to npm because it contains sensitive domain data that defines the very problem your software is trying to solve.

The Solution

Since both the frontend and the backend are written in typescript, they can simply share the code. This can be done by a library which both the frontend and the backend import.

Press enter or click to view image in full size
A diagram showing how a frontend and a backend can depend on the same code in a monorepo
Backend and Frontend importing common code as a library

This way, updating the library in one place makes the changes instantly happen to both the frontend and the backend, and any issues are caught during your build pipeline, for example.

Making a Monorepo with Npm Workspaces and Nx

Using npm workspaces (introduced in v7) and Nx, we’ll create a monorepo with two applications, a frontend and a backend, as well as a common library the purpose of which we’ll define in a bit.

Note that when running most of the upcoming commands, we’ll be using the -w flag alongside the name of the package we want it to affect:

npm run -w package-name-here command

This will allow us to run all our commands from the root of the monorepo without having to navigate to any of the packages.

1. Creating a monorepo

First, let’s create a new monorepo called HelloWorld. The easiest way to do this is using the following npx command:

npx create-nx-workspace@latest HelloWorld --preset=npm

If you’re prompted with the following:

Need to install the following packages:
create-nx-workspace@16.5.0

Then type y and press enter.

You may also be prompted with the following:

Enable distributed caching to make your CI faster …
Yes I want faster builds
No

This is outside of this article’s scope, so you should answer with no and move on.

You will end up with the following:

Press enter or click to view image in full size
A list of folders representing the result of creating a new monorepo using nx
Your folder structure after scaffolding the monorepo using the npx command

Inside package.json, you will see a section called workspaces:

Press enter or click to view image in full size
The package.json file created by nx when scaffolding our monorepo, including an entry for workspaces representing where our packages are / can be
The workspaces entry in the package.json file after creating the monorepo

This is how npm knows what packages you have in your monorepo. The packages/* string tells it that everything under the packages folder is a package of the monorepo.

We’ll add another string here to allow our libs to also be detected:

"workspaces": [
"packages/*",
"libs/*"
]

Then create an empty folder in your project root called libs:

Press enter or click to view image in full size
A list of folders including a folder named libs which we created to add our libraries to it at a later section
Creating a libs folder to store our common libraries

We’ll add a library to this folder in an upcoming section.

Next, let’s add two applications to the monorepo, a backend application using NestJS and a frontend application using Angular.

2. Creating a backend

The NestJS application can be created and added by running the following command:

npx nx nest new packages/backend

After this finishes, nest will have created a new git repository. Make sure to delete it. This can be done by deleting the .git folder under packages/backend which can be done using the following command:

rm -rf packages/backend/.git

To run the backend, you don’t have to go to its package, instead you can do the following:

npm run -w backend start:dev

Where the -w backend part tells npm to run the start:dev command in the backend workspace. The name backend comes from the name field in the package.json file of the backend package.

The backend is ready at this point.

2.1. Creating a frontend

To create a frontend using Angular, first install the cli (globally):

npm install -g @angular/cli

then run the following command

cd packages
ng new frontend

You can simply spam enter through the prompts as they don’t have any relevant effects for this article.

You can test out the frontend package using the following:

npm run -w frontend start

3. Creating a common library

The difficulty in creating a monorepo involving typescript comes from the fact that we need to transpile the code to javascript first, while still keeping things like types, source maps, etc. accessible to anyone who imports the library.

Frameworks like NestJS and Angular take care of compiling themselves for us, and we don’t really import them anywhere anyway. It’s a different story for a library though.

In short, to create a common library, we’ll have to think about a few things:

  • Transpiling from Typescript to Javascript
  • Keeping types intact
  • Source maps for allowing the original code to easily be reached using IDE tools like “Go to References”
  • Automatically rebuilding when changes happen
  • Cause any place this library was imported to be re-built if it has its own watch mode.

As an example, we’ll make the library represent a very basic logger which surrounds a given message with a bunch of emojies and logs it to the console. So let’s get to it!

Press enter or click to view image in full size
An example usage and output result of the fancy logger where calling it with a message logs that message to the console surrounded by emojies
An example call and result of using our proposed logger library

First, we’ll create the library using an npm command. We’ll do this using the following command:

npm init -w ./libs/fancy-logger -y

This will create a package.json file under libs/fancy-logger with (more or less) the following contents:

Press enter or click to view image in full size
The resultant package.json file after creating a new package in our workspace using an npm command
The resultant package.json file after creating our library using an npm command

To allow us to use typescript in this package, we’ll have to install typescript and add a couple of entries to the scripts in this package.json.

Get Aziz Nal’s stories in your inbox

Join Medium for free to get updates from this writer.

We’ll start by installing typescript with the following command:

npm i -w fancy-logger --dev typescript

Reminder: make sure you’re running these commands from the root of the monorepo. You don’t have to navigate to your package in the terminal as npm provides a way to target a certain package when running a command.

Having installed typescript, replace the scripts section in fancy-logger‘s package.json

"scripts": {
"build": "tsc",
"build:watch": "tsc -w"
},

The first script, build, builds the library once, while build:watch builds the library once then watches for any changes and rebuilds the library.

One more thing is to add a tsconfig.json file. You can copy-paste the following into a new file under libs/fancy-logger/tsconfig.json:

{
"compilerOptions": {
"moduleResolution": "nodenext",
"module": "commonjs",
"baseUrl": "./",
"outDir": "./dist",
"lib": ["es6"],
"declaration": true,
"declarationMap": true,
"target": "ES2021",
"sourceMap": true
}
}

Here’s a breakdown of what’s going on in the file:

  • moduleResolution: "nodenext" as recommended by typescript docs
  • module: "commonjs" sets what module system the generated javascript will use.
  • baseUrl: "./" to allow absolute imports like src/../.. (docs)
  • outDir: "./dist" is the location for the built library.
  • lib: ["es6"] to include type declarations for es6 features like Promise , Set , Map , etc.
  • declaration: true to generate .d.ts files and add them to the build
  • declarationMap: true to allow the resultant .d.ts files to be found by the IDE (using things such as “Go to Reference”)
  • target: "ES2021" sets the target syntax for the generated javascript
  • sourceMap: true to allow debuggers to find the javascript being currently run.

You can change lib , declaration , declarationMap , target , and sourceMap without too much hassle.

module and moduleResolution change how javascript is generated with regards to imports both internal to your library as well as how your library itself is imported. These properties affect each other, and should be changed while referencing the docs. nodenext is generally a good choice for projects using a modern version of node.

Also, be mindful when changing baseUrl or outDir as they need changes to be done in other files as well as we’ll see shortly.

At this point I also recommend adding a .gitignore either at the top of your monorepo (which probably already exists so just add these to its end) or in this library itself with the following contents:

dist
node_modules

Next, we have to set a couple of entries in the library’s package.json to make sure it’s discovered and ran correctly when it’s imported.

The entries we’re going to set are main and types , the first of which sets the main javascript file representing the entry for the library and exporting everything from it.

types specifies where the generated *.d.ts can be found. We’ll specify this one as a glob pattern to catch all the type files in the project.

Here’s how the main and types entries will look (other entries omitted):

{
"main": "dist/index.js",
"types": "dist/*/**.d.ts"
}

Make sure to replace any existing main or types if they happen to be there.

Now we can get to writing some code. Start by creating an index.ts file under libs/fancy-logger/ and then create a folder next to it called src:

A list of folders highlighting the fancy-logger library with an index.ts file and an adjacent src folder
Creating a src folder and an index.ts file in our new fancy-logger library

index.ts will be the entry point for the library, exporting everything we want from the library.

Let’s add a basic class whose job is only to log a message with some nice emojies. We’ll add this one under libs/fancy-logger/src/fancy-logger.ts :

export class FancyLogger {
static log(message: string) {
console.log(`🚀🚀🚀 ${message} 🚀🚀🚀`);
}
}

Then export it from the adjacent index.ts file:

export * from "./src/fancy-logger";

Here is the basic file organization so far:

Press enter or click to view image in full size
An overview of the folder structure, fancy-logger class, and the index.ts file showing how that class is exported
How the index.ts file and fancy-logger class should look and where they should be

Let’s build the package and see if everything goes to plan. Run the following command:

npm run -w fancy-logger build

Having done so, you’ll see a new dist folder in the library with the following contents:

Press enter or click to view image in full size
A list of folders highlighting how the dist folder was generated after running the build command
The resultant dist folder after building our library

Behold, a built library! Now we can import it in our frontend and backend and do a little example with it. Note that the contents of the dist folder could be different if you changed the module or moduleResolution properties in your tsconfig.json.

To import the package, add it to the dependencies array of the both the backend and frontend’s package.json files like so:

"dependencies": {
// ...
"fancy-logger": "*"
}

After adding it for both the frontend and the backend, run npm install so npm links and sets the packages up for us. This is something we only have to do once when we first setup the library.

Now if you check your node_modules folder at the top of the monorepo you’ll see the fancy-logger package in there:

A list of folders highlighting how the fancy-logger library can now be found in node_modules after npm has linked it
npm links the fancy-logger library and makes it available in node_modules

Let’s use the package in the frontend and the backend to log a message when they first startup. For the backend, we can do this in the main.ts file under packages/backend/src:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

// Importing our library 🚀
import { FancyLogger } from 'fancy-logger';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);

// Using the library 🚀
FancyLogger.log('Backend has started');
}
bootstrap();

We’ll do the same for the frontend in the app.component.ts file under packages/frontend/src/app/:

import { Component } from "@angular/core";

// Importing our library 🚀
import { FancyLogger } from "fancy-logger";

@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.css"],
})
export class AppComponent {
title = "frontend";

constructor() {
// Using the library 🚀
FancyLogger.log("Frontend is up and running");
}
}

Note that the output for the frontend will be in the browser console, not the terminal.

Now, let’s start the backend, frontend, and library in watch mode and see what’s happening.

The commands are:

npm run -w backend start:dev

npm run -w frontend start

npm run -w fancy-logger build:watch

You can run each of these in a separate terminal / pane.

Now for the moment of truth, here’s the output:

Press enter or click to view image in full size
The outputs of the backend terminal and the frontend console showing how the logger is working correctly
The terminal and console outputs of the backend and the frontend with our logged messages

With the backend on the left and the frontend on the right, you can clearly see the output of our logger.

Now, since our library is running in watch mode, changing the surrounding emojies should result in the logs instantly changing as well:

Press enter or click to view image in full size
a demo showing how changing the fancy-logger class and saving causes those changes to propagate to the importing applications causing a rebuild
Changing the logger class and saving the file causes importing apps to be rebuilt

You can find a repository with everything we’ve done in this article here:

Caveats

  1. We’re not using a bundler. There’s still a lot of room for optimizations such as tree-shaking and faster compilation.
  2. As you might’ve seen in the terminal when running Angular, the frontend does not like commonjs modules.
  3. We haven’t setup nx.json to handle our build operations. I recommend you take a look at the docs for that.

Bonus: Script for instantly creating a library

Look, I get it. We’re all lazy. No one has time to repeat all of that work above when creating a new library, so I created a script which creates one for you! You can find it here:

Conclusion

Using npm workspaces and nx, we created a typescript monorepo with two applications and a library, where the two applications import and use the library.

We created a typescript library and setup its tsconfig to generate javascript which can be imported into our applications, with a watch mode to re-build the library on changes and also cause the importing applications to re-build themselves.

References

--

--

ITNEXT
ITNEXT

Published in ITNEXT

ITNEXT is a platform for IT developers & software engineers to share knowledge, connect, collaborate, learn and experience next-gen technologies.

Aziz Nal
Aziz Nal

Written by Aziz Nal

Full Stack Web Developer. Very passionate about software engineering and architecture. Also learning human languages!

Responses (1)