Setting up a TypeScript multi-package mono-repo for @scoped/packages.

How to use NPM workspaces for scoped packages.

Lerna and Yarn workspaces provide tools to ease the lives of multi-package mono-repo maintainers. Since version 7 NPM follows suit by adding support for workspaces, opening up the world of multi-package tooling for NPM-only users.

While I was setting up a project, I tried to search for a way to combine workspaces with scoped packages. It wasn't immediately clear how to set this up. After figuring out how to do it, I thought I'd share this for those who are looking to do the same.

In a couple of steps I'll walk through setting up a multi-package mon0-repo for a TypeScript project that uses Jest for testing. Let's get right to it.

Step 1: Setting up the top-level NPM project

The first thing we need to do is create a directory for our project and initiate a private NPM package.

mkdir -p ~/Sites/acme-corp

cd ~/Sites/acme-corp

echo '{"private": true}' >> package.json

Next we can install some dependencies that we'll use to compile and test our packages. We'll be using jest as the test framework.

npm install --save-dev typescript @types/node jest ts-jest @types/jest

The packages will be located in the packages directory. The package.json needs to modified to NPM where to find the packages will be located.

{
  "private": true,
  "workspaces": [
    "./packages/*"
  ],
  // ...
}

Lastly, to prepare for our test setup we can add a test script.

{
  // ...
  "scripts": {
    "test": "jest"
  },
  // ...
}

Step 2: Create scoped packages in workspaces

NPM's documentation shows how to create packages in a work space. To create a scoped workspace package, use the --scope option. For demonstration purposes we're going to re-create the is-even and is-odd packages with the same dependency setup they have. The is-odd will depend on the is-even package to implement its logic.

npm init --scope=@acme-corp -y -w ./packages/is-even
npm init --scope=@acme-corp -y -w ./packages/is-odd

This creates two package.json files that look something like this:

{
  "name": "@acme-corp/is-even",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

Although many other workspace tutorials keep the package name and directory name in sync, this does not seem to be a requirement. Scoped packages have a name that is different from the folder name.

As stated before, our is-odd clone will use the @acme-corp/is-even package. This mean we need to add the @acme-corp/is-even as a dependency.

npm install @acme-corp/is-even -w ./packages/is-odd

The install command uses the -w option to indicate which package should receive the dependency. This can be used to add any dependencies to the workspace. We can now implement the packages. For each package we create an index.ts file.

// contents of ./packages/is-even/index.ts

export function isEven(i: number): boolean {
    return i % 2 === 0;
}
// contents of ./packages/is-odd/index.ts

import { isEven } from '@acme-corp/is-even';

export function isOdd(i: number): boolean {
    return isEven(i) === false;
}

With the code in place we can start configuring everything that's needed to ship the packages.

Step 3: Configuring out TypeScript setup

This is probably the most complicated step. The majority of our TypeScript configuration will be centralised, preventing the configuration of our packages to get out of sync. First up is a top-level config file (tsconfig.build.json) that we'll use as configuration baseline.

{
    "exclude": [
        "**/*.test.ts",
        "**/*.stub.ts",
        "node_modules",
        "dist"
    ],
    "compilerOptions": {
        "target": "ES2015",
        "module": "commonjs",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "declaration": true,
        "paths": {
            "@acme-corp/*": ["./packages/*/"]
        }
    }
}

The configuration baseline uses a dynamic paths configuration to resolve the packages locally. This is not strictly needed, but it allows you to try out your packages together in a local script.

Our main top-level configuration file tsconfig.json will extend our build configuration.

{
    "extends": "./tsconfig.build.json",
    "compilerOptions": {
        "baseUrl": "./"
    },
    "include": [
        "./packages/*/**.ts"
    ]
}

Next, each of our packages will need their own configuration file  located at ./packages/[package]/tsconfig.pkg.json.

{
    "extends": "../../tsconfig.build.json",
    "compilerOptions": {
        "outDir": "./dist/"
    },
    "include": [
        "./**/*.ts"
    ]
}

To compile the project, each package.json will require a compile script.

{
  // ... 
  "scripts": {
    "compile": "rm -rf dist/* && tsc -p tsconfig.pkg.json"
  },
  // ...
}

We can now compile our code in bulk by using the npm run compile instruction with the -ws option to trigger the script on all of the packages.

npm run compile -ws

With the code compiled, the packages can be release. Before we do that, let's add some tests to ensure what we ship makes any sense.

Step 4: Testing our packages

As mentioned before, we'll be using Jest to test our code. The first thing to do is to add the tests. These are the tests for the is-odd package, located in ./packages/is-odd/index.test.ts.

import { isOdd } from "./index";

describe('isOdd', () => {
    test('it detects even numbers', () => {
        expect(isOdd(0)).toBe(false);
        expect(isOdd(2)).toBe(false);
    });

    test('it detects odd numbers', () => {
        expect(isOdd(1)).toBe(true);
        expect(isOdd(3)).toBe(true);
    });
});

To get our tests to run, we need to create a Jest configuration file located at the root of our project (jest.config.js). This setup generates a code-coverage report and uses ts-jest which allows us to use TypeScript.

module.exports = {
    preset: 'ts-jest',
    testEnvironment: 'node',
    collectCoverage: true,
    coverageDirectory: "coverage",
    collectCoverageFrom: [
        "packages/**/*.{ts,js,jsx}"
    ],
    coveragePathIgnorePatterns: [
        "jest.config.js",
        "/node_modules/",
        "/dist/",
    ],
    moduleNameMapper: {
        '^@acme-corp/(.*)$': '<rootDir>/packages/$1/'
    }
};

The moduleNameMapper setting provides local module resolution that is compatible with  tsconfig.build.js, which is required because ts-jest does not use tsconfig.js configuration.

With our top-level test script in place, we can now run the tests for our entire project using a single command.

npm run test

Running the tests for a single package is also possible.

npm run test -- packages/is-odd

Step 5: Publishing the packages

As our last step we'll go ahead and release our packages by publishing then to NPM. Before shipping our packages we need to ensure everything is ready to go. First, we'll want to be sure that all our packages are compiled.

npm run compile -ws

Next we'll add an .npmignore file to each package to ensure the packages only contain the files intended for shipping. In the following example everything is ignored and shippable artefacts are excluded from the ignore list.

*
!dist/*
!package.json
!readme.md

This list ensures only the dist folder, the package.json and the readme are shipped. We're now ready to publish our packages. We can do this by running the following command:

npm publish --workspaces --access public

During the publishing you may be required to authenticate against the NPM registry is you haven't done so already.

Et voila!

That's all it takes to setup a multi-package mono-repo for a TypeScript project. I've published the packages and pushed the entire setup to a github repository in case you want to see it in its entirety.

Subscribe for updates

Get the latest posts delivered right to your inbox.