Sharing Configurations Within a Monorepo

Matti Bar-Zeev - Apr 22 '22 - - Dev Community

In this post join me as I share my approach for sharing different tooling configurations within a monorepo.

Within almost any JS/TS project you encounter there are several configurations which define how the project’s supporting tools, such as testing, linting, TS compiling and more, should act. These configurations hold instructions on what files to “look at”, what to exclude, what rules to follow etc.

The Challenge

When dealing with a single project which produces a single artifact we tend to create these configuration files once and rarely mess with them throughout the project’s life, but when dealing with a monorepo which has several packages within it, each has its own needs for different configurations.
We keep on adding new packages as we go along and we are facing the challenge of keeping our configurations robust and easy to maintain.

This maintenance usually means 2 main things -

  • Creating a solid base/common configurations which all can share
  • Enabling an easy way to extend and override these base configurations when needed

In this post I will focus on the 3 main configurations a monorepo has -

  • Jest testing configuration
  • ESlint configuration
  • TypeScript configuration

Let’s dive in!


Jest testing

Jest testing configuration defines things like what testing env is used (node? jsdom?), what files to look at, is it verbose or not and many other options.
While some packages need to run in node env (for example a package representing an ESlint plugin) others need to run in jsdom. How do we go about it?

Well, the first thing I did was to create a base configuration file under the root of the monorepo, called jest.config.base.js, with the following content:

const config = {
   verbose: true,
   testRegex: '(/__tests__/.*|(\\.|/)(test))\\.(jsx?|tsx?)$',
   testEnvironment: 'jsdom',
};

module.exports = config;
Enter fullscreen mode Exit fullscreen mode

In the example above I set Jest to be verbose, including specific files and running on jsdom environment as a default. At the end I’m exporting this configuration and we will see why in a sec.
Each package has a test yarn command in it’s package.json file, and this command simply runs Jest:

"scripts": {
    "test": "jest",
    . . .
},
Enter fullscreen mode Exit fullscreen mode

Unless defined specifically Jest knows to look for a configuration file which resides on the directory from which it was launched. It will not automatically look up the folders tree, so each package which needs to have Jest testing should have a jest.config.js file in it. So how do we extend it?

In the package itself we create a jest.config.js file and extend the base configuration like this:

const sharedConfig = require('../../jest.config.base');
module.exports = {
   ...sharedConfig,
   testEnvironment: 'node',
};
Enter fullscreen mode Exit fullscreen mode

We’re importing the base configuration and use destructing to extend and override it. In the example above we change the testEnvironment to be “node”.

From here on, any configuration which we believe should be common goes into the base configuration and we extend and override it if we need to.

ESlint

ESlint configuration defines how we would like our code to be inspected and what rules should be applied. We can set things like what presets we would like to extend, what global variable we want to exist etc.
Different packages tend to have different ESlint rules, for instance, some packages do not need to have a certain global defined in ESlint configuration. So how do we go about it?

Well, ESlint is a bit more advanced from Jest in that sense. The tool knows to automatically look up the folders tree if it does not find an ESlint configuration in the current location it was launched from.
So we can define an eslintrc.json file in the root of the monorepo which can look something like this:

{
   "root": true,
   "env": {
       "browser": true,
       "commonjs": true
   },
   "extends": ["eslint:recommended", "plugin:react/recommended"],
   "parserOptions": {
       "ecmaVersion": 2020,
       "sourceType": "module"
   },
   "globals": {
       "describe": true,
       "it": true,
       "expect": true,
       "jest": true
   },
   "rules": {}
}
Enter fullscreen mode Exit fullscreen mode

Notice the “root” property in the example above. This means that ESlint will look up until it reaches this configuration and will not continue further (you can read more about it here).
Now that we have this set, how do we go about extending and overriding it?

The nice thing about ESlint configuration is that it merges the configuration in the folder tree. So it will append the different configuration it encounters layer by layer as it goes through the folder tree, having the root configuration as the base and then, as the nesting goes down, merging them one on top of the other.

Does it make sense? No?
Ok - let’s say I have this root configuration as described above and in a nested package I have this .eslintrc.json:

{
   "globals": {
       "cy": true
   }
}
Enter fullscreen mode Exit fullscreen mode

The result would be that I have these globals defined:

{
   "globals": {
"describe": true,
        "it": true,
        "expect": true,
        "jest": true,
        "cy": true
   }
}
Enter fullscreen mode Exit fullscreen mode

That’s some powerful stuff! Moving on.

TypeScript

TypeScript configuration defines how we would like our TypeScript Compiler (TSC) to work.
It defines a set of rules like whether it should produce source maps, do we want to generate type declaration files etc.

We start with a base configuration at the root of the monorepo called tsconfig.base.json, which may look something like this:

{
   "compilerOptions": {
       "noImplicitAny": true,
       "removeComments": true,
       "preserveConstEnums": true,
       "sourceMap": true,
       "allowJs": true,
       "moduleResolution": "Node",
       "declaration": true,
       "allowSyntheticDefaultImports": true,
       "esModuleInterop": true
   }
}
Enter fullscreen mode Exit fullscreen mode

In order for TSC to work it requires to have a tsconfig.json in the directory from which we launch the tsc command, so in each package we want to have TSC we need to create that file, but how do we extend the base configuration in that case?

TS configuration has a property called “extends” which surprisingly enough extends any other configuration you might have. In our case we would like to extend the base configuration. Our tsconfig.json may look like this:

{
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
       "module": "CommonJS",
       "outDir": "dist/cjs",
       "declarationDir": "dist/types"
   },
   "files": ["index.ts"],
   "include": ["src/**/*"],
   "exclude": ["node_modules", "**/*.test.js"]
}
Enter fullscreen mode Exit fullscreen mode

In the example above we extend the base configuration but add a few specific settings. There is always room to be more fine-tuned but I believe you get the idea :)
(BTW, you can read more on how to use TSC different configuration to produce a hybrid package in this article)

Wrapping up

That’s it - your monorepo now shares its basic configurations and allows you to extend and override each within its own package. This gives a lot of maintenance power and allows you to focus on the real stuff :)

As always if you're familiar with other techniques of achieving this or have questions, be sure to share them with the rest of us in the comments below.

Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻

Photo by Nicolas Thomas on Unsplash

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .