18 min read

Monorepos with PNPM Workspaces

Monorepos with PNPM Workspaces

PNPM is not just a modern package manager but also a great tool for managing lean monorepos. Learn how to set up and use PNPM workspaces from scratch including TypeScript Project References for building and typechecking incrementally.

Monorepos PNPM Workspaces TypeScript Project References

In this article, we will explore how to set up and use PNPM workspaces to manage simple monorepos. PNPM is a fast, disk space-efficient package manager that supports monorepo structures out of the box. It has its limitations, but it is a great starting point and can be used as a foundation for more complex setups.

So, if you are looking for a simple way to manage your monorepo, PNPM workspaces are a great choice. They allow you to group multiple packages together, share dependencies, and run scripts across all packages in the workspace. And even if you already know that you will need a more capable monorepo tool like Nx, PNPM Workspaces can still be a valuable part of your toolchain and you can use Nx on top of PNPM Workspaces.

PNPM as a Package Manager

Leaving the monorepos aside for a moment, PNPM is a package manager that is known for its speed and disk space efficiency. It uses a unique approach to store packages in a global store and creates symlinks to them in the project directories. This means that multiple projects can share the same package version without duplicating it on disk, which saves space and speeds up installations. Similarly to Yarn, PNPM has parallelized installations, which makes it faster than npm in most cases anyway.

Installation

To install PNPM, you can use npm or Yarn:

npm install --global corepack@latest
corepack enable pnpm
corepack use pnpm@latest-10

Note: Corepack is a tool that comes with Node.js and allows you to manage package managers like PNPM, Yarn, and others. It is recommended to use Corepack to ensure that you are using the correct version of PNPM. The specific version of PNPM used in this book is 10.x, but you can use any version that is compatible with your project.

Basic Usage

On the surface level, PNPM commands look very similar to npm commands, so if you are already familiar with npm, you will feel right at home. Here are some common PNPM commands and their npm equivalents:

Taskpnpm Commandnpm Command
Install dependenciespnpm installnpm install
Install a packagepnpm add <pkg>npm install <pkg>
Install dev dependencypnpm add -D <pkg>npm install -D <pkg>
Remove a packagepnpm remove <pkg>npm uninstall <pkg>
Update dependenciespnpm updatenpm update
Run a script defined in pkgpnpm run <script>npm run <script>

The real difference comes when you start exploring how PNPM works under the hood, especially in the context of symlinks and the global store.

In order to achieve the disk space efficiency, PNPM uses a global store to keep all downloaded packages. When you install a package, PNPM creates a symlink in your project directory that points to the package in the global store. This means that if you have multiple projects that depend on the exact same package version, they will all point to the same location on disk, saving space.

The actual folders in the projects node_modules directory are symlinks to the global store. You can also imagine a symlink as a desktop shortcut that points to a file or folder in another location. This way, PNPM can avoid installing the same package multiple times, no matter which project it is used in. So, when you have many projects on your local machine, PNPM can be help you save a lot of disk space compared to npm or Yarn. But it is not just useful when you have many projects on your local machine, it is also useful when you have many projects in a monorepo as each project has a separate node_modules directory, but they can all share the same packages from the global store and when you add a dependecy to one of your projects inside the monorepo, PNPM will symlink it as well.

How PNPM constructs the node_modules directory

When you install a package with PNPM, it creates a node_modules directory in your project. The first thing it does is to create a .pnpm directory inside the node_modules directory where it creates a nested structure for all peer dependencies and dependencies of the packages you install. Each package is stored in its own directory inside .pnpm, and the package name and version are used to create the directory name. For example, if you install foo@1.0.0 which depends on bar@1.0.0, PNPM will create the following structure:

Notice, how the bar package is symlinked from the foo package. This is how PNPM resolves dependencies and ensures that packages can share their dependencies without duplicating them on disk. However, each dependency has a self reference to its global store location, which is why you see the <store> placeholder in the example above. This is the actual symlink which is the hard linke to the content addressable store.

Next, direct dependencies are handled. foo is going to be symlinked into the root node_modules folder because foo is a dependency of the project:

In order to find the global store path, you can run the following command:

pnpm store path

This will output the path to the global store, which is usually located in your home directory under .pnpm-store. But you will not find meaningful package names there, as the packages are stored in a content-addressable format which is a hash value based on the content of the packages. The actual package names are stored in the .pnpm directory inside the node_modules directory of your project.

PNPM Workspaces

Workspace is a feature of PNPM that allows you to manage multiple packages in a single repository, giving you the ability to create a monorepo with PNPM. Workspaces are defined in the pnpm-workspace.yaml file, which is located at the root of your repository. This file specifies which packages are part of the workspace and allows you to run commands across all packages in the workspace. Whenever you add a new dependency to a package in the workspace, PNPM will automatically link it to the global store and create the necessary symlinks in the node_modules directory of that package.

Setting Up a PNPM Workspace

First, let’s create a new directory and initialize a PNPM workspace:

mkdir my-pnpm-workspace
cd my-pnpm-workspace
pnpm init 

If you want to follow along, you can also check out the complete example repository that we’ll be building throughout this article:

Next, create a pnpm-workspace.yaml file in the root of your workspace:

packages:
  - 'packages/*'

Note: The workspace file is yaml formatted, so make sure to use the correct indentation and syntax. You can specify multiple patterns to include different directories in your workspace. Right now, we are only including packages in the packages directory, but you can add more directories as needed.

Now, create a packages directory and add some packages to it:

mkdir packages
cd packages
mkdir package-a package-b
cd package-a
pnpm init
cd ../package-b
pnpm init

Now you have a basic PNPM workspace with two packages: package-a and package-b. You can add dependencies to these packages just like you would in a regular PNPM project. For example, to add a dependency to package-a, you can run:

cd packages/package-a
pnpm add -D typescript

This will add the typescript package to package-a and create the necessary symlinks in the node_modules directory. You can also add dependencies to package-b in the same way.

cd packages/package-b
pnpm add -D typescript

Notice how each package has its own node_modules directory and package.json file, but they can all share the same packages from the global store as PNPM uses symlinks and hard links to avoid duplicating packages on disk. This is one of the key benefits of using PNPM workspaces.

Let’s have a look at the package.json file of package-a:

{
  "name": "package-a",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "devDependencies": {
    "typescript": "^5.8.3"
  }
}

You can see that package-a has a dev dependency on typescript, which is installed in the global store and symlinked in the node_modules directory of package-a. Also notice the version number, which is automatically set to 1.0.0 when you run pnpm init -y. You can change it later if you want to publish the package to a registry, but if you are using the package only within the workspace, you can leave it as is and do not have to worry about semantic versioning.

At this moment, the PNPM workspace is pretty much empty and does not have any code in it. This is the current structure of the workspace:

JSON package.json
JSON package.json
JSON package.json
YML pnpm-workspace.yaml

In order to change this, we are going to add TypeScript to our workspace and create two simple TypeScript libraries to our packages.

Adding TypeScript to the Workspace

Let’s start by adding TypeScript to the workspace in the root directory:

pnpm add -D typescript --workspace-root

Notice how you always have to use the --workspace-root flag when you want to add a dependency to the root of the workspace. This is because PNPM workspaces allow you to have a separate node_modules directory for each package, but you can also have a shared node_modules directory in the root of the workspace.

Next, we will add a base configuration file which will later be extended by each package such that all our packages have a consistent TypeScript configuration. Create a tsconfig.base.json file in the root of the workspace:

{
  "compileOnSave": false,
  "compilerOptions": {
    // Output
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": false,

    // Module resolution
    "module": "NodeNext",
    "target": "ES2022",
    "moduleResolution": "NodeNext",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "isolatedModules": true,

    // Strictness
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noImplicitReturns": true,

    // Source mapping
    "sourceMap": true,
    "inlineSources": true,

    // Project references
    "composite": true,
    "skipLibCheck": true,
  },
  "exclude": ["node_modules", "dist", "build"]
}

Then, we also need to add a tsconfig.json file to be able to build all the projects in the workspace at once. This file will extend the base configuration file and include all packages in the workspace:

{
  "extends": "./tsconfig.base.json",
  "files": [],
  "references": [
    { "path": "./packages/package-a" },
    { "path": "./packages/package-b" }
  ]
}

This configuration file tells TypeScript to use the base configuration file and includes all packages in the workspace as project references. This way, you can build all packages in the workspace at once by running pnpm tsc --build in the root directory.

But what even is a TS Project Reference? For this you first need to understand that tsc is the TypeScript compiler, which can compile TypeScript code to JavaScript. On the one hand it has the functionality of a classic compiler as it is able to lex, parse an AST, transform and emit code. But on the other hand it also is not just a compiler, but also a build orchestration tool. In other words, it is able to understand the dependencies between different TypeScript files and packages, and can build them in the correct order. This is what project references are all about. They allow you to define dependencies between different TypeScript projects (in our case, packages) and let the TypeScript compiler know how to build them.

A reference can be defined in any tsconfig.json file, and it looks like this:

{
  "compilerOptions": {
    "composite": true
  },
  "references": [
    { "path": "../package-b" }
  ]
}

This tells the TypeScript compiler that the current project depends on package-b, and it should be built before the current project. The composite option is required for project references to work, as it enables the TypeScript compiler to generate the necessary metadata for the project.

Project references are both powerful as they allow you to make typechecking incremental, but they are also necessary for intellisense in your IDE, as it allows the TypeScript compiler to understand the dependencies between different projects and provide accurate type information. If you import something from another internal package in your workspace, the project reference is needed to resolve a Go to Definition or Find All References in your IDE or editor correctly.

Alright, now that we know what project references are, let’s initialize the two packages in our workspace with TypeScript.

Initializing TypeScript in the Packages

First, let’s initialize package-a with TypeScript:

cd packages/package-a
pnpm add -D typescript

Instead of initializing TypeScript with tsc --init, we will create a tsconfig.json file manually, which will extend the base configuration file we created earlier. Create a tsconfig.json file in the package-a directory:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
  },
  "include": ["src/**/*"],
  "references": []
}

Now, let’s create a simple TypeScript file in the src directory of package-a. Create a src directory and add an index.ts file:

mkdir src
touch src/index.ts

This is a very special TypeScript file, as it will be the entry point of our package and is often referred to as a barrel file. It will only have Export Declarations, which means it will only export identifiers from other files contained in the same package. This is a common pattern in TypeScript projects, as it allows you to keep some of your code private while still explicitly exporting the public API of your package.

Add the following code to src/index.ts:

export { foo } from './lib/hello-world.js';
// Note: The `.js` extension is necessary here because we are using ESNext modules 
// and the TypeScript compiler will emit the `.js` files in the `dist` directory.

But foo is not defined yet, so we need to create the lib directory and add the hello-world.ts file:

mkdir lib
touch lib/hello-world.ts

Now, add the following code to lib/hello-world.ts:

export const foo = () => {
  console.log('Hello, World!');
};

Now we have a super simple TypeScript package that exports a single function foo which logs “Hello, World!” to the console.

Let’s now repeat the same steps for package-b. First, navigate to the package-b directory and initialize TypeScript:

cd ../package-b
pnpm add -D typescript

Then create a tsconfig.json file in the package-b directory:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../package-a" }
  ]
}

Notice how we added a reference to package-a in the references array. This tells TypeScript that package-b depends on package-a, and it will be built after package-a. But we also have to add the dependency in the package.json file of package-b. Open the package.json file in the package-b directory and add the following dependency:

{
  "name": "package-b",
  "type": "module",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "dependencies": {
    "package-a": "workspace:*"
  },
  "devDependencies": {
    "typescript": "^5.8.3"
  }
}

This tells PNPM that package-b depends on package-a, and it will use the version of package-a that is defined in the workspace. The workspace:* syntax is a special PNPM Workspace feature that allows you to reference packages in the same workspace without specifying a version number. This way you don’t have to worry about semantic versioning, but be wary that this only works when package-a is not published to a registry.

Now, let’s create a simple TypeScript file in the src directory of package-b. Create a src directory and add an index.ts file:

mkdir src
touch src/index.ts

Add the following code to src/index.ts:

import { foo } from 'package-a';

foo();

Okay, now we are done with the setup and can try to build our packages. Before we can build our packages, we need to make sure that all the node_modules for each package are installed correctly. T his is also important even when you only have internal depedencies, as PNPM will create the necessary symlinks in the node_modules directory of each package.

For this, go back to the root of the workspace and run pnpm install. Then, run the TypeScript compiler with the --build flag, such that it will orchestrate the build of all packages in the right order:

pnpm install
pnpm tsc --build

This will first build package-a and then package-b in the right order. Note that, the --build flag requires you to use project references, but project references can have other benefits as well.

If you are facing slow intellisense in a large monorepo, you could use project references to reduce the number of files the TS language server has to check for each file, based on the project references.

Conclusion & Outlook

PNPM is not just a powerful package manager, but also great for monorepos. We have learned how to use TS Project References to enable tsc --build and therefore have set up a fully functional TypeScript monorepo with PNPM workspaces.

On the one hand, PNPM Workspaces are lean and easy to use, but on the other hand, they lack some of the advanced features that more complex monorepo tools like Nx provide. For example, Nx offers advanced caching, task scheduling, and dependency graph analysis, which can significantly speed up your development workflow in larger projects. It is also able to automatically update your TS project references when you add or remove packages, which is a huge convenience factor. The great thing is that you don’t have to choose one or the other, as you can use PNPM Workspaces as a foundation and then build on top of it with Nx if you need more advanced features later on. In fact, opting into Nx nowadays is easier than ever and should be no more than a single command:

pnpm nx init
Stefan Haas

Stefan Haas

Senior Software Engineer at Microsoft working on Power BI. Passionate about developer experience, monorepos, and scalable frontend architectures.

Comments

Join the discussion and share your thoughts!