Screenshot displaying the folder structure of packages at the root level.

Npm workspaces using TypeScript

Pablo Garcia
4 min readSep 11, 2022

--

If having a monorepo wasn’t complicated enough, trying to share code between multiple packages inside the monorepo usually becomes a battle that ends on “let’s just duplicate the code”. Workspaces —introduced into npm starting from version 7— let us solve this without manually running npm link.

A common setup is a repo that includes both a client and a server app:

<your_repo>/
- client/package.json
- server/package.json
- package.json

If we wanted to share code, we could create a directory at the same level as both client and server (but this could end with issues importing files from outside the app’s directory), or we find a complex solution, or duplicate the code, or use npm link and create separate packages inside this same repo —but don’t ask me to set this up for a teammate or external contributor—.

TL;DR;

  1. Use the -w npm flag to init a new workspace: npm init -w <path_to_workspace>.
  2. Add a build script to the workspace using TypeScript: "build": "tsc index.ts ...".
  3. Run npm install at the root directory to make it available to every app inside the monorepo —even to other workspaces—.
  4. Lastly, make sure to run the workspaces’ build script for all workspaces when the root app is built: "build": "npm run build --workspaces --if-present && …".

Like this:

npm init -w ./packages/<your_package>
npm install <some_dependency> -w ./packages/<your_package>
// ./package.json
{
"name": "whatever-no-scope-needed",
"build": "npm run build --workspaces --if-present && npm run test && rm -rf dist && npm run lint && tsc -b",
"workspaces": [
"packages/<your_package>"
]
}
// ./packages/<your_package>/package.json
{
"name": "@yourapp-packages/<your_package>",
"version": "1.0.0",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"private": true,
"scripts": {
"build": "rm -rf dist && tsc index.ts --declaration --allowJs --outDir dist"
}
}
// From root directory (makes your workspaces available)
npm install
// ./server/src/index.ts
// NOTE: DO NOT RUN `npm i @ourapp-packages/<your_package>`
// this is handled by npm automatically.
import MyError from '@ourapp-packages/errors'

Solution

With workspaces, this is streamlined. Let’s say we have an error library that we would like to share with both the client and server:

<your_repo>/
- packages/errors/package.json
- client/package.json
- server/package.json
- package.json

We can directly run npm commands in the context of workspaces by using the -w flag (we can do this for every npm command):

npm init -w ./packages/errors

This does two things

1. First, it adds a new entry in the root package.json for this new workspace:

"workspaces": [
"packages/errors"
],

2. Second, it creates the ./packages/errors directory and initializes a package.json:

{
"name": "@ourapp/errors",
"version": "1.0.0",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"private": true,
"scripts": {
"build": "rm -rf dist && tsc index.ts --declaration --allowJs --outDir dist"
}
}

A few things to keep in mind;

  • @ourapp” scope can be anything but try to use something unique, so it doesn’t collide with external modules, such as @ourapp-packages or @ourapp-common. This has the side benefit of letting your developers know that it is a package from your app and not an external library.
  • I replaced main property with module because I use bundlers for the client and server apps. No need to assume it will be used without it like other external libraries.
  • 🚨 I added the build script which uses TypeScript instead of a regular bundler to compile the code. This also helps with declarations, which are essential for shared code.
  • I added types: "dist/index.d.ts" for obvious reasons.
  • I added private: true because this is a privately shared package.

Workspace dependencies

To install dependencies for workspaces, you can navigate into the directory and install it the regular way npm install <dep> or use the npm -w flag:

npm i <dependency> --save -w packages/<your_package>

Building

When building the full product, we build the workspaces first by prepending a command to the build script inside the root package.json:

"scripts": {
"build": "npm run build --workspaces --if-present && npm run test && rm -rf dist && npm run lint && tsc -b",
},

The key is that we are using TypeScript to build each package and we use the --if-present flag to tell npm to build the package if there is a build script before we build our own:

npm run build --workspaces --if-present

We can do the same for testing.

Installing workspaces

npm install

Run npm install at the root level and npm will make the workspaces available for use without having to manually install them for each app as a dependency.

Usage

Your workspace can look something like this:

// ./packages/errors/index.ts
import get from "lodash/get";
export default class MyError {
constructor(opts: any) {
const prop = get(opts, 'prop', false)
// ...
}
}
export * from "./something-else";

Your server or client now would look like this without having to install the packages as dependencies:

// ./server/src/index.ts
import MyError from '@ourapp-packages/errors'
function SomeController() {
throw new MyError(...)
}

--

--

Pablo Garcia

Staff Sofware Architect 2 at PayPal, M.S. in Computer Science w/specialization in Computing Systems, B.Eng. in Computer Software.