Did you ever want to create one project, but with local packages? To be able to import local packages from other local packages? And the best part, to have a single nodemodules directory? To have a _single tsc
to rebuild changed packages (but keep the others as-is)? And as a nice bonus, decrease both transpile and the time to start the program?
The solution is to use a
monorepo
! This guide shows the step by step to manully setup a monorepo with NPM + TypeScript.
Goals
-
There will be just one
node_modules
folder (in the root of the monorepo). -
Each piece (read: local package) of the product will have its own folder with its own
package.json
,tsconfig.json
but use the monorepo'snode_modules
. -
Running
tsc
in the monorepo root transpiles all packages in order. -
Using
import .. from
can reference local packages without errors.
Not covered by this guide: Building production builds — this guide is focused on the developer experience :)
TL;DR; — Demo repo?
Yes, there's a demo repo.
Monorepo basics
The monorepo consists of two pieces.
-
The monorepo itself (the root folder with it's configuration files, like
package.json
,node_modules
,tsconfig.json
and other) -
One folder per local package, located in the packages folder. Each local package has its own configuration, such as
package.json
,tsconfig.json
andsrc
-folder and other (but nonode_modules
). -
I recommend to set up a
tsconfig.package.json
in the root of the monorepo, and reference that.
.. and the tools of course, npm
and tsc
🙂
Package? Local packages?
It's a side note, but “packages” as used for a developer usually refers to packages downloaded from the internet by npm
or yarn
. This guide refers both to remote packages (located on the internet and probably developed by someone else than you) and local packages.
Core monorepo setup
Let's create the most basic folders. This demo project will be named “SuzieQ”.
# Create new empty folder
mkdir monorepodemo
cd monorepodemo
# Create subdirectories
mkdir src
./package.json
Next, create a ./package.json
with the following content:
{
"name": "suzieq",
"private": true,
"scripts": {
"compile": "tsc -b -w -i",
"eslint": "eslint src/**/**.{ts,tsx}",
"eslint:fix": "eslint src/**/**.{ts,tsx} --fix"
},
"devDependencies": {},
"workspaces": [
"packages/*"
]
}
⚠️Make sure the section “workspaces” is present, npm
is dependent on this.
Add core dependencies
A great place to add core dependencies is here. This also creates the single node_modules
directory needed in the entire monorepo.
npm install [@types/node](http://twitter.com/types/node) --save-dev
npm install typescript
npm install ts-node --save-dev
./tsconfig.json
Next, create a ./tsconfig.json
with the following content.
⚠️️ composite
must be set to true
and files
must be set to an array (see https://www.typescriptlang.org/docs/handbook/project-references.html#overall-structure)
{
"compilerOptions": {
"incremental": true,
"target": "es2019",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"rootDir": "./src",
"outDir": "./superbin"
},
"files": [
"./src/index.ts"
]
}
./src/index.ts
Create the ./src/index.ts
file:
console.log("This is index.ts");
… and test it works:
# Build, this will create the "superbin" folder
tsc -b
# Run index.ts
node -r ts-node/register/transpile-only src/index.ts
Prepare packages folder
The next step would be to create the packages folder, where all local packages will be placed:
mkdir packages # This folder should be found directly under "./"
cd packages
So a short reminder:
-
Each package will be placed in a subfolder inside the folder
/packages
. -
The monorepo config will be stored in the root (i.e
"./"
)
Add a local package
This guide shows how to manually create all files needed for a package. It's also possible to use npm, see below. This section can be repeated as many times as necessary.
The package we will be creating is named ValidatorHelper
(just to pick anything).
# create package folder inside of "packages"
mkdir ValidatorHelper
cd ValidatorHelper
./packages/ValidatorHelper/package.json
Inside of the newly created folder, create a package.json
as (this means you will now have two package.json, one in the monorepo root and one here):
{
"name": "@suzieq/validatorhelper",
"version": "1.0.0",
"description": "",
"main": "bin/index.js",
"scripts": {
"compile": "tsc -b",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
⚠️ Change the name and make sure the name is unique (and it must not interfere with npm downloadable packages). It's recommended to choose a scope name, such as “@suzieq” (and it will keep your monorepos node_modules
directory cleaner, read more below).
⚠️ Do not use _same _“scope name” and “project directory name”. npm
will “optimizations” in node_modules where it removes the scope name from the directory name🙄
If you prefer to decrease control, basically the same result can be achieved through:
cd / # Go to root of monorepo
npm init --scope=@suzieq -w ./packages/ValidatorHelper
The “hack” of node_modules
Now, this is super important. npm
will automatically process any package.json
found under “packages” and create a link between node_modules
to this folder.
inside node_modules
link to the packages\validatorhelper folder
So after each time, a local package has been created (or really, a package.json
has been created/modified) then go to the monorepo root and run npm install
(and optionally check node_modules
for the updated link).
⚠️ Do not run npm install
in the package directory. node_modules
is from now on “owned” by the monorepo and should exist at the root of the monorepo. More on this topic below.
./packages/ValidatorHelper/tsconfig.json
Now there are many examples where this file extends an already existing tsconfig.json
. This works just fine, but for the sake of simplicity, that feature isn't used here.
rootDir
, outDir
and composite
settings must be specified.
This configuration will make the package build its result to ./bin
with included TypeScript definitions.
{
"compilerOptions": {
"incremental": true,
"target": "es2019",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"rootDir": "./src",
"outDir": "./bin",
"composite": true
},
}
./packages/ValidatorHelper/src/index.ts
Finally, create the folder src
and the index.ts
file.
console.log("Debug: Loading ValidateHelper/index");
export function ValidateUserName(name: string): boolean {
return (name !== undefined && name.length > 10);
}
Verify package is ok
At this point, it's possible to check that everything seems OK.
# Change working directory
cd /packages/ValidatorHelper
# Examine the combined tsconfig-file (this and the root one)
tsc --showconfig
# Testrun the module (as-is)
node -r ts-node/register src/index.ts
Running tsc
here should transpile without errors, and produce a bin
folder containing two files: index.js
and types.d.ts
.
It's the same files that will be produced if tsc
is run at the monorepo root.
# Just check tsc succeeds without creating "bin" folder:
tsc --noEmit
# Build (choose the one you prefer)
tsc -b --listEmittedFiles --diagnostics
tsc -b -v
# Test run
node bin\index.js
Repeat :)
Add some more local packages of any type before moving on to the next section.
Using tsc to transpile all modified packages (in a specific order)
Now, this is where it gets interesting 😁 By issuing tsc
at the top level, it can run through all modified packages and build them. This is especially efficient when running tsc
in watch mode. But in order to do that, tsconfig.json
needs to be modified to list the package(s).
Open the monorepo root ./tsconfig.json
and add the references
section as shown below.
⚠️ tsc
will build in the order listed, so this means you have to have some knowledge around dependencies between packages.
️️⚠️If the files
configuration is missing (or badly set), then your typescript files can be built **twice 😟 **resulting in “junk” files in your packages src-folders. A good start to troubleshoot this is to set files
to []
.
{
"compilerOptions": {
// leave as-is
},
"references": [
{
"path": "./packages/ValidatorHelper"
}
],
"files": [
"./src/index.ts"
]
}
Try it:
# Transpiles packages in the order listed *and*
# then build the files referenced by "paths"
cd / # Go to root of monorepo
tsc -b -v
At this point…
At this point each local package will have:
-
been built producing a
./packages/<packagename>/bin
-folder -
exist as a link from
node_modules/@scopename/<packagename>
to./packages/<packagename>
NPM Packages: Add reference between local packages
We can use a loophole in the module resolution in TypeScript/JavaScript works. 😁If you prefer to do this “the real way”, read the next section.
Now, all our local packages are immediately available without any changes to any files (want to know more about this? Read this link). Just open ./src/index.ts
and add this, and you're good to go.
import ValidatorHelper from '@suzieq/validatorhelper'
Adding better references
Open a package.json
and add a reference to a local (already existing) package with the following.
"dependencies": {
"[@suzieq/products](http://twitter.com/suzieq/products)": "*"
}
The *
means that we allow for any version.
Avoid file references?
So npm
recommends to use file references:
npm install .\packages\<packagename> -w packages\<packagename>
This command will give something like this in the package.json
and it doesn't really give any advantages.
"dependencies": {
"[@suzieq/products](http://twitter.com/suzieq/products)": "file:packages/Products"
}
NPM Packages: Add an external npm package to a local package
Now, this is important, because this is different compared to how it's usually done. To add an external npm package to a local npm package, the following procedure must be followed:
Go to the root of the monorepo and run npm
to install a package in workspace by passing the -w
parameter. The parameter accepts either the name of the package (in this case, @suzieq/validatorhelper
) or the path (./packages/ValidatorHelper
)
# Go to the root of the monorepo
cd /
# Add axios to ValidatorHelper
npm install axios -w @suzieq/validatorhelper
# Or use the path
npm install axios -w .\packages\ValidatorHelper
This will install axios
in the monorepos node_modules
, and modify the ./packages/ValidatorHelper/packages.json
. Verify the success by checking that only a single node_modules
folder exists at the monorepo root.
⚠️ So to be super clear, **do not do this **(and if you manage to do it, see troubleshooting below for a fix):
cd packages\ValidatorHelper
npm install axios
References in the right place?
Does it really matter the reference to a download package is stored in the monorepo package.json
vs. having it packages\packagename\package.json
? No, it will work anyway.
Start test for one specific or all packages
To start a npm
command for all packages, just add -ws
:
# Run tests for a specific package from root
npm test -w @suzieq/validatorhelper
# Run tests for all local packages
npm test -ws
Start tsc for one specific or all packages
# Build a specific package from root
cd /
tsc -b packages\ValidatorHelper
# Build all
cd /
tsc -b -v -w -i
Troubleshooting
Repairing…
If you've ended up in a position where nothing seems to help, repair the setup by doing these steps:
-
delete the links found in
node_modules\@scopename\
(deleting the entirenode_modules
isn't needed). runnpm install
to recreate them. -
delete any
bin
orout
folders found in the entire system -
verify that all the packages
package.json
:s are ok -
stop all running
tsc
:s, delete alltsconfig.tsbuildinfo
file and restarttsc
without the incremental flag
VSCode and/or TypeScript doesn't detect/see the local package?
Restart VS Code :) Also, make sure the outDir
setting tsconfig.json
matches the main setting in package.json
tsconfig.json
package.json
node_modules
Q: I've run npm install packagename
inside a local package, and (in error) created packages\packagename\node_modules
.
A: Just delete the node_modules
folder and retry to install the package from the monorepo root.
No workspaces found
Q: I cannot run npm install axios --workspace=@xyz/abc
Solution 1:
This section is missing in the monorepo package.json
:
"workspaces": [
"packages/*"
]
Solution 2: Have you actually spelled the name correctly? :)
a) If you've chosen to use the path to the package, make sure it exists. Eg test dir ./thepath
b) If you've chosen to use the *name *of the package (as stated in the packages package.json
), make sure you copy it perfectly (including the slash).
c) Are the outDir
setting tsconfig.json
matching the main
setting in package.json
?
EDUPLICATEWORKSPACE
Q: Trying to work with NPM i get EDUPLICATEWORKSPACE
A: The value of the name
field is used more than once in one of the package.json
:s found in one of the subdirectories of packages
. NPM will list the conflicting files, just make sure all local packages has unique names.
npm WARN workspaces abc/xyz filter set, but no workspace folder present
Q: I cannot run npm install axios --workspace=@suzieq/apple
A: So the workspace setup seems OK, but the submitted name of the workspace isn't found. Make sure the name parameter of --workspace
can be found in a package.json
:s name
field:
"name": "@suzieq/xyz"
Cannot set properties of null (setting ‘dev')
This error can be caused by several things.
Solution 1:
Begin with deleting all files in node_modules\@suzieq
. To recreate the contents, just run npm install
.
Verify the folder node_modules\@suzieq
was created without errors.
Solution 2:
A: Inside the apple-package:s folder, open package.json
. Either devDependencies
or dependencies
is broken.
Solution 3: Ensure that all the “name” properties in package.json files has unique names.
When using tsc, it produces .js files in my src-folders
The files section is missing in tsconfig.json
. See above.
"files": [
"./src/index.ts"
]
Demo repository?
Yes, there is one. See https://github.com/tomnil/monorepoplate