Setting up a TypeScript multi-package mono-repo 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.jsonNext 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/jestThe 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-oddThis 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-oddThe 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 our 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 -wsWith 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 testRunning the tests for a single package is also possible.
npm run test -- packages/is-oddStep 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 -wsNext 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.mdThis 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 publicDuring 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.