Monorepo Setup with NPM and TypeScript

By Tomas Nilsson

January 21st, 2022

image

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's node_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 and src-folder and other (but no node_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_modulesinside node_modules

link to the packages\validatorhelper folder

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 entire node_modules isn't needed). run npm install to recreate them.

  • delete any bin or out folders found in the entire system

  • verify that all the packages package.json:s are ok

  • stop all running tsc:s, delete all tsconfig.tsbuildinfo file and restart tsc 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

tsconfig.json

package.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

Enjoy :)

Further Reading

Painless monorepo dependency management with Bit



Continue Learning