Npm workspaces using TypeScript
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;
- Use the
-w
npm flag to init a new workspace:npm init -w <path_to_workspace>
. - Add a
build
script to the workspace using TypeScript:"build": "tsc index.ts ..."
. - Run
npm install
at the root directory to make it available to every app inside the monorepo —even to other workspaces—. - 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 withmodule
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(...)
}