A monorepo is a single repository that is used to manage multiple projects. In this blog post, we’ll explore how to set up a simple monorepo for two npm packages. All we need is already built into npm and TypeScript.
Whenever we have to develop multiple interdependent npm packages in parallel, we have two options:
The benefit of (2) is that it’s easier to keep the packages in sync: We can install and build all packages at the same time. And, in Visual Studio Code, we can jump between packages while editing.
“Monorepo” sounds fancy, but my use case for it is actually relatively simple. I am currently working on a minimal static site generator that is called Stoa. It comes in two parts:
@rauschma/stoa
contains the tool and is published to the npm registry.@rauschma/demo-blog
is just an (unpublished) directory and contains:
main
module invokes the command line interface of Stoa and passes it configuration data (incl. the JSX views for rendering pages).I started developing via so-called local path installations:
cd demo-blog/
npm install ../stoa/
Afterward, demo-blog/package.json
has the following dependency:
{
"name": "@rauschma/demo-blog",
"dependencies": {
"stoa": "file:../stoa",
···
},
···
}
This approach has several upsides:
npm link
, which involves two steps and leads to global changes.demo-blog/node_modules
there is a symbolic link (symlink) to stoa/
, which means that, as Stoa is developed, we’ll see the changes from demo-blog/
. (Caveat: yarn
does not use symlinks, it copies the dependency’s files over.)But it also has significant downsides:
demo-blog/package.json
is set up now, it can’t use the version of Stoa in the npm registry.stoa
and demo-blog
are not de-duplicated. That is fatal for some packages – for example, we can’t use hooks in React and Preact if the render function and the JSX components come from different packages.After my failed attempt with local path installations, I set up a monorepo for stoa
and demo-blog
.
In a previous blog post, I explained how to produce ESM modules via TypeScript. That’s also what I have configured for both packages in the monorepo. It has the following file system layout:
stoa-packages/
stoa/
package.json
tsconfig.json
ts/
gen/
client/
test/
dist/
demo-blog/
package.json
tsconfig.json
ts/
gen/
client/
dist/
stoa/package.json
looks like this:
{
"name": "@rauschma/stoa",
"type": "module",
"exports": {
"./gen/*": "./dist/gen/*.js",
"./client/*": "./dist/client/*.js"
},
"typesVersions": {
"*": {
"gen/*": [
"dist/gen/*"
],
"client/*": [
"dist/client/*"
]
}
},
"dependencies": {
···
}
}
"type"
tells Node.js to interpret .js
files as ESM modules (not CommonJS modules).
"exports"
configures the JavaScript level. It means that, e.g.:
stoa-packages/stoa/dist/gen/util/regexp-tools.js
'@rauschma/stoa/gen/util/regexp-tools'
.In other words, this setting achieves two things:
'dist'
in module specifiers.'.js'
in module specifiers."typesVersions"
makes sure that TypeScript finds the type definitions (.d.ts
files) that it needs.
This is what’s in demo-blog/package.json
:
{
"name": "@rauschma/demo-blog",
"type": "module",
"dependencies": {
"@rauschma/stoa": "*",
···
},
"scripts": {
"all": "node ./dist/gen/main.js all"
}
}
The command npm run all
is defined via "scripts"
and starts generation via the JavaScript version of demo-blog/ts/gen/main.ts
. The latter file contains:
import { cli } from '@rauschma/stoa/gen/core/cli';
const projectDirPath = url.fileURLToPath(
new url.URL('../../', import.meta.url));
cli({
projectDirPath,
···
});
So far, we are still not in monorepo territory: Each of the two packages stoa
and demo-blog
exists in its own (mostly separate) directory.
A workspace is what npm calls a monorepo: A directory with subdirectories that are npm packages. We turn stoa-packages/
into a workspace by adding a package.json
to it:
stoa-packages/
package.json
node_modules/
@rauschma/
stoa -> ../../stoa
demo-blog -> ../../demo-blog
stoa/
demo-blog/
stoa-packages/package.json
looks like this:
{
"name": "stoa-packages",
"workspaces": [
"stoa",
"demo-blog"
]
}
Unfortunately, npm overloads the term “workspaces”: The packages in an npm workspace are also called workspaces.
Now we can do:
cd stoa-packages/
npm install
Then this happens:
stoa
and demo-blog
are installed into stoa-packages/node_modules
.stoa-packages/node_modules
also contains symbolic links to stoa-packages/stoa/
and stoa-packages/demo-blog/
.stoa
and demo-blog
do not have their own node_modules
directory. However, when they import modules, Node.js looks for them in the next node_modules
higher up in the file tree. The symlink in node_modules
enables demo-blog
to import from @rauschma/stoa
.
What have we achieved?
node_modules
.demo-blog
can import stoa
as if the former were a standalone directory and the latter were a published package.demo-blog
automatically sees all changes we make in stoa
.We still need to compile each of the two packages separately via TypeScript. We can fix that via project references, which are the TypeScript name for a monorepo. We need to create three files:
stoa-packages/tsconfig.json
stoa-packages/stoa/tsconfig.ref.json
stoa-packages/demo-blog/tsconfig.ref.json
The file system layout now looks like this:
stoa-packages/
tsconfig.json
stoa/
tsconfig.json
tsconfig.ref.json
ts/
gen/
client/
test/
dist/
demo-blog/
tsconfig.json
tsconfig.ref.json
ts/
gen/
client/
dist/
This is stoa-packages/tsconfig.json
:
{
"files": [],
"references": [
{
"path": "./stoa/tsconfig.ref.json"
},
{
"path": "./demo-blog/tsconfig.ref.json"
},
],
}
The normal stoa-packages/stoa/tsconfig.json
(which we need in standalone mode) contains:
{
"compilerOptions": {
"rootDir": "ts",
"outDir": "dist",
"target": "es2021",
"lib": [
"es2021", "DOM"
],
"module": "ES2020",
"moduleResolution": "Node",
"strict": true,
"noImplicitOverride": true,
// Needed for CommonJS modules: markdown-it, fs-extra
"allowSyntheticDefaultImports": true,
//
"jsx": "react-jsx",
"jsxImportSource": "preact",
//
"sourceMap": true,
"declaration": true,
"declarationMap": true, // enables importers to jump to source
}
}
This tsconfig.json
has a sibling tsconfig.ref.json
that is required due to the project reference in stoa-packages/tsconfig.json
:
{
"extends": "./tsconfig.json",
"include": ["ts/**/*"],
"compilerOptions": {
"composite": true,
},
}
Let’s examine the properties:
"extends"
lets us add the properties to the standalone tsconfig.json
that we need to make project references work. Alas, we can’t add them to tsconfig.json
itself because then it wouldn’t work in standalone mode anymore."include"
is required for project references.compilerOptions.composite
must be true
for project references.What have we achieved? We can now use single commands to clean, build, watch (etc.) all packages.
For example, we can add these scripts to stoa-packages/package.json
:
{
···
"scripts": {
"clean": "tsc --build --clean",
"build": "tsc --build",
"watch": "tsc --build --watch"
},
···
}
Another benefit is that we can click (Mac: cmd-click, Windows: ctrl-click) on something that demo-blog
imported from stoa
and Visual Studio Code will jump to the original source code – and not to the .d.ts
file (details).
Sometimes, we make a change in one package and Visual Studio Code doesn’t see that change in another package that depends on it. There are two things we can do when that happens:
.d.ts
file also usually helps.I have not shown you how to publish stoa-packages/stoa
to npm and how to turn stoa-packages/demo-blog
into a downloadable archive, but that’s relatively easy to achieve.
We have seen how we can set up a very simple monorepo by only using what’s already built into npm and TypeScript. That makes it much easier to develop multiple packages in parallel.
I managed to preserve the ability to compile package demo-blog
on its own. I haven’t seen that in the other TypeScript project references setups that I’ve come across.
I am very happy with this setup, but still have three wishes related to TypeScript:
tsconfig
."typesVersions"
to a package.json
to make "exports"
work with TypeScript. I’m hoping that TypeScript will be able to derive this information from "exports"
in the future.npm workspaces:
TypeScript project references:
project-references-demo
(by Ryan Cavanaugh) is a repository that demonstrates how to use project references.Other material: