An actual complete guide to typescript monorepos
Tags: typescript, monorepos, pnpm
Published: 2021-06-03 00:23:44

---
title: An actual complete guide to typescript monorepos
published: true
description: Most typescript monorepo guides leave a lot out, this guide aims to not leave anything out.
tags: typescript, monorepos, pnpm
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ytzloy97lyjcxomluojh.png
canonical_url: https://cryogenicplanet.tech/posts/typescript-monorepo
---

When I was setting up our production monorepo at [modfy.video](http://modfy.video), I found most typescript monorepo guides were quite lacking in addressing a lot of more detailed problems you run into or how to solve them with modern solutions.

This guide aims to do that for 2021, with the best in class tooling at this time. That said when using modern advanced tooling, you can and will run into some compatibility problems so this guide might be slightly esoteric for you.

This guide is really optimized towards typescript monorepos that also contain packages that can be deployed but really should work for any typescript monorepos.

# Getting started

For this guide, we will be using `pnpm` but this should mostly work the space with `yarn` just swap out `pnpm` workspaces with `yarn` workspaces. (This will likely not work well with `npm` and would not recommend using that)

## Base directory

To get started we need to setup our base directory it will contain a few key files, which will be explained in more detail below. 

Your base directory once completed will look something like

```bash
.
├── jest.config.base.js
├── jest.config.js
├── lerna.json
├── package.json
├── packages
│   ├── package-a
│   ├── package-b
│   ├── package-c
│   ├── package-d 
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
└── tsconfig.json
```

We can start by setting up our `package.json`

*Feel free to swap `pnpm` out with `yarn`*

```json
{
  "name": "project-name",
  "repository": "repo",
  "devDependencies": {},
  "scripts": {
    "prepublish": "pnpm build",
    "verify": "lerna run verify --stream",
    "prettier": "lerna run prettier",
    "build": "lerna run build",
    "test": "NODE_ENV=development lerna run test --stream"
  },
  "husky": {
    "hooks": {
      "pre-commit": "pnpm prettier",
      "pre-push": "pnpm verify"
    }
  },
  "dependencies": {},
  "private": true,
  "version": "0.0.0",
	"workspaces": [
    "packages/*"
  ]
}
```

Some basic dependencies we can install are

```bash
pnpm add -DW husky lerna
# Nice to haves
pnpm add -DW wait-on # wait on url to load
pnpm add -DW npm-run-all # run multiple scripts parrellely or sync
pnpm add -DW esbuild # main build tool
```

These are definitely not all the dependencies but others will be based on your config

Finally your `.gitignore` can look like this

```
node_modules
lerna-debug.log
npm-debug.log
packages/*/lib
packages/*/dist
.idea
packages/*/coverage
.vscode/
```

### Setting up workspace

Setting up `pnpm` workspaces are really easy you need `pnpm-workspace.yaml` file like

```yaml
packages:
  # all packages in subdirs of packages/ and components/
  - 'packages/**'
  # exclude packages that are inside test directories
  - '!**/test/**'
  - '!**/__tests__/**'
```

Full documentation can be found here [https://pnpm.io/workspaces](https://pnpm.io/workspaces)

### Orchestration

There are a few options for orchestration tools you can use like [rushjs](https://rushjs.io/) but for this guide we'll just use lerna. Specifically tho, we are not using lerna for package management or linking but just for orchestration.

Similar to the about workspace file we need a `lerna.json`  where we set the packages

```json
{
  "packages": ["packages/*"],
  "npmClient": "yarn",
  "useWorkspaces": true,
  "version": "0.0.1"
}
```

*Note as we don't care about lerna for package management, the npmClient doesn't really matter.*

The only lerna command we care about is `lerna run <command>` this lets us run a script across all our packages. So `lerna run build` will build all the packages in our repository

### Setting up Typescript

The example below is for work with react, please change the configuration accordingly if you don't need react at all.

For typescript monorepos, we should use a relatively new typescript feature called project references, you can learn more about it here [https://www.typescriptlang.org/docs/handbook/project-references.html](https://www.typescriptlang.org/docs/handbook/project-references.html)

Few things to not about it are:

- The only `tsc` command you have is `tsc --build` which is typescript's multistage build
- You also have to use the `composite` flag which has a few added requirements [https://www.typescriptlang.org/docs/handbook/project-references.html](https://www.typescriptlang.org/docs/handbook/project-references.html)

To use project references you have to manually add the path to each reference like the following

```json
// tsconfig.json
{
  "compilerOptions": {
    "declaration": true,
    "noImplicitAny": false,
    "removeComments": true,
    "noLib": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "target": "es6",
    "sourceMap": true,
    "module": "commonjs",
    "jsx": "preserve",
    "strict": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "lib": ["dom", "dom.iterable", "esnext", "webworker"],
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true
  },
  "exclude": ["node_modules", "**/*/lib", "**/*/dist"],
  "references": [
    { "path": "./packages/package-a/tsconfig.build.json" }, 
		// if you tsconfig is something different
    { "path": "./packages/package-b" },
    { "path": "./packages/package-c/" },
    { "path": "./packages/interfaces/" },
  ]
}
```

Finally it is good to add these dependencies as global dependencies

```bash
pnpm add -DW @types/node typescript
```

### Eslint + Prettier (Optional)

Feel free to use your own prettier and eslint config here, but this is just the one I like and use.

Dependencies

```jsx
pnpm add -DW eslint babel-eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-prettier eslint-config-prettier-standard eslint-config-react-app eslint-config-standard eslint-plugin-flowtype eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-node eslint-plugin-prettier eslint-plugin-promise eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-simple-import-sort eslint-plugin-standard prettier prettier-config-standard
```

```json
// .prettierrc
"prettier-config-standard"
```

```json
// .eslintrc
{
  "env": {
    "browser": true,
    "es6": true,
    "node": true
  },
  "extends": [
    "react-app",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "prettier-standard"
  ],
  "parserOptions": {
    "project": "./tsconfig.json"
  },
  "plugins": [
    "react",
    "@typescript-eslint",
    "react-hooks",
    "prettier",
    "simple-import-sort"
  ],
  "rules": {
    "no-use-before-define": "off",
    "prettier/prettier": [
      "error",
      {
        "endOfLine": "auto"
      }
    ],
    "simple-import-sort/exports": "error",

    "simple-import-sort/imports": [
      "error",
      {
        "groups": [
          // Node.js builtins. You could also generate this regex if you use a `.js` config.
          // For example: `^(${require("module").builtinModules.join("|")})(/|$)`
          [
            "^(assert|buffer|child_process|cluster|console|constants|crypto|dgram|dns|domain|events|fs|http|https|module|net|os|path|punycode|querystring|readline|repl|stream|string_decoder|sys|timers|tls|tty|url|util|vm|zlib|freelist|v8|process|async_hooks|http2|perf_hooks)(/.*|$)"
          ],
          // Packages
          ["^\\w"],
          // Internal packages.
          ["^(@|config/)(/*|$)"],
          // Side effect imports.
          ["^\\u0000"],
          // Parent imports. Put `..` last.
          ["^\\.\\.(?!/?$)", "^\\.\\./?$"],
          // Other relative imports. Put same-folder imports and `.` last.
          ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
          // Style imports.
          ["^.+\\.s?css$"]
        ]
      }
    ],
    "import/no-anonymous-default-export": [
      "error",
      {
        "allowArrowFunction": true,
        "allowAnonymousFunction": true
      }
    ]
  }
}
```

```
// .eslintignore
*/**.js
*/**.d.ts
packages/*/dist
packages/*/lib
```

### Testing (Optional)

Here's a configuration for basic testing with jest 

```jsx
pnpm add -DW jest ts-jest @types/jest tsconfig-paths-jest 
```

```jsx
// jestconfig.base.js
module.exports = {
  roots: ['<rootDir>/src', '<rootDir>/__tests__'],
  transform: {
    '^.+\\.ts$': 'ts-jest'
  },
  testRegex: '(/__tests__/.*.(test|spec)).(jsx?|tsx?)$',
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
  collectCoverage: true,
  coveragePathIgnorePatterns: ['(tests/.*.mock).(jsx?|tsx?)$'],
  verbose: true,
  testTimeout: 30000
}
```

```jsx
// jest.config.js
const base = require('./jest.config.base.js')

module.exports = {
  ...base,
  projects: ['<rootDir>/packages/*/jest.config.js'],
  coverageDirectory: '<rootDir>/coverage/'
}
```

## Packages

Now that we have setup the base repo, we can setup the individual packages

We will cover few broad types of packages here:

1. Typescript only packages, that is packages that don't need to be deployed with javascript support. Examples, interfaces, or internal only packages
2. Packages that depend on other packages
3. Packages with testing
4. Packages with build steps
5. Packages that are meant to be deployed to support javascript

Regardless of the type of package, all packages will consist of same basic config

```jsx
├── package.json // can be a standard package.json
├── README.md // can be whatever
├── src
│   ├── index.ts
├── tsconfig.json 
```

For the `tsconfig.json` it should be structured like

```json
// tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist", // Your outDir
  },
  "include": ["./src"]
}
```

For the `package.json` it can be structured normally but should ideally contain these scripts

```json
// package.json
{
// other
"scripts": {
    "prettier": "prettier --check src/",
    "prettier:fix": "prettier --write src/",
    "lint": "eslint . --ext .ts,.tsx",
    "lint:fix": "yarn lint --fix",
    "verify": "run-p prettier lint", // using npm-run-all
    "verify:fix": "yarn prettier:fix && yarn lint:fix",
    "build": "", // whatever the build script is
  },
}
```

### Typescript only packages

It depends on the use case but if this is like an interfaces package, it likely requires no other configuration. (not even a build script)

For packages that might need a build script to run regardless, there will be more guidance below.

### Packages that depend on other packages

When  `@projectName/package-a` depends on `@projectName/package-b` we should add the following steps to let typescript know about this dependency.

First in `package-b` we add the following to the `tsconfig`

```json
// packages/package-b/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "composite": true // the composite flag
  },
  "include": ["./src"]
}
```

Second in `package-a` we reference this package like

```json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    }
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["dist/*"],
  "references": [{ "path": "../package-b/tsconfig.json" }]
}
```

### Packages with test

For packages that are using `jest` for testing

```jsx
// packages/package-a/jest.config.js
// Jest configuration for api
const base = require('../../jest.config.base.js')

// Only use the following if you use tsconfig paths
const tsconfig = require('./tsconfig.json')
const moduleNameMapper = require('tsconfig-paths-jest')(tsconfig) 
module.exports = {
  ...base,
  name: '@projectName/package-a',
  displayName: 'Package A',
  moduleNameMapper
}
```

For testing you need to have to separate tsconfigs, this can be structured like default + build, or default + test. For this example, we will use default + build

```json
// tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "composite": true,
    "rootDir": ".",
    "emitDeclarationOnly": true,
  },
  "include": ["src/**/**.ts", "__tests__/**/**.ts"],
  "exclude": ["dist"]
}
```

Essentially we don't want to build our tests, so we can just ignore them to not cause errors

```jsx
//tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "exclude": ["**/*.spec.ts", "**/*.test.ts"]
}
```

After this whenever you are building use the `tsconfig.build.json` like `tsc --build tsconfig.build.json`

### Packages with build steps

Obviously there are tons of typescript build tools and this category is very broad, even in our monorepo we have four-five different typescript build tools

Think of this more as a broad set of tools you can use to nicely achieve this

1. `esbuild` - I cannot stress how awesome esbuild is, its really great and fairly easy to get started with [https://esbuild.github.io/](https://esbuild.github.io/)
2. `vite` - I certainly didn't know vite had a library mode, but it does and it is very good. This would definitely be my recommendation for building any frontend packages for `react/vue/etc`

    [https://vitejs.dev/guide/build.html#library-mode](https://vitejs.dev/guide/build.html#library-mode)

3. `tsup` - This is a minimal configuration build tool which wraps around esbuild and has some nice features.

(All these tools are built upon esbuild, it is really mind blowingly fast)

The only catch with `esbuild` and `vite` is you don't get a `.d.ts` file. You can generate a `.d.ts` file by adding `"emitDeclarationOnly": true` to  `tsconfig` and then running `tsc --build`

If you are using `tsup` you can use the `--dts` or `-dts-resolve` flag to generate the same.

All this being said, I would follow this issue on `swc` another fast compiler because it might come with the ability to generate `.d.ts` files in the future. [https://github.com/swc-project/swc/issues/657#issuecomment-585652262](https://github.com/swc-project/swc/issues/657#issuecomment-585652262)

Base configurations

1. `esbuild`

    ```json
    // package.json
    {
    "scripts" : {
    	"build" : "esbuild src/index.ts --define:process.env.NODE_ENV=\\\"production\\\" --bundle --platform=node --outfile=lib/index.js"
    	"postbuild" : "tsc --build"
    	}
    }
    ```

2. `vite`

    This is a vite config for `react` and it has a few steps

    ```jsx
    // vite.config.ts
    import path from 'path'

    import { defineConfig } from 'vite'
    import tsconfigPaths from 'vite-tsconfig-paths' // can remove if you don't use ts config paths

    import reactRefresh from '@vitejs/plugin-react-refresh'

    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [reactRefresh(), tsconfigPaths()],
      build: {
        lib: {
          entry: path.resolve(__dirname, 'src/index.ts'),
          name: 'packageName',
          fileName: 'index'
        },
        rollupOptions: {
          external: ['react'],
          output: {
            globals: {
              react: 'react'
            }
          }
        }
      }
    })
    ```

    ```jsx
    // package.json
    {
    "scripts" : {
    		"build:tsc": "tsc --build && echo 'Completed typecheck!'",
        "build:vite": "vite build",
        "bundle:tsc": "node build/bundleDts.js",
        "build": "npm-run-all build:vite build:tsc  bundle:tsc",
    }
    }
    ```

    For vite specifically we need to bundle all the `.d.ts` files into a single declaration file

    ```jsx
    const dts = require('dts-bundle') // package that does this for us
    const pkg = require('../package.json')
    const path = require('path')

    dts.bundle({
      name: pkg.name,
      main: 'dist/src/index.d.ts',
      out: path.resolve(__dirname, '../dist/index.d.ts')
    })
    ```

3. `tsup` - is the easiest and just that `tsup src/* --env.NODE_ENV production --dts-resolve`

    The only caveat is less configurable than esbuild itself

### Packages that are meant to be deployed to support javascript

These packages all have to follow the build steps laid out above but this is something I wanted to explicitly address cause I did not see any other guide talk about this.

In development you want your packages to point to typescript, but in production you want to point to javascript + a type file. Unfortunately this is not natively supported by `npm` or `npmjs` (to the best of my knowledge), luckily here is where `pnpm` comes in clutch.

`pnpm` supports the following config, [https://pnpm.io/package_json#publishconfig](https://pnpm.io/package_json#publishconfig)

```json
// package.json
{
    "name": "foo",
    "version": "1.0.0",
    "main": "src/index.ts",
    "publishConfig": {
        "main": "lib/index.js",
        "typings": "lib/index.d.ts"
    }
} 
```

```json
// will be published as 
{
    "name": "foo",
    "version": "1.0.0",
    "main": "lib/index.js",
    "typings": "lib/index.d.ts"
}
```

The catch is you **have to use `pnpm publish`** if you use `npm publish` **it will not work.**

General things to note about publishing, you need access to public and the files you want to include

```json
{
	"name": "@monorepo/package",
  "main": "src/index.ts",
  "license": "MIT",
  "browser": "dist/index.js", // can directly set browser to js
	"publishConfig": {
    "access": "public",
    "main": "dist/index.js",
    "typings": "dist/index.d.ts"
	  },
	  "files": [
	    "dist/*"
	  ]
}
```

You will likely have to use these broad categories together when in production, so feel free to mix and match.

## Things I don't have good solutions for

1. Creating new package with a template, lerna has a cli thing for this but I couldn't seem to be able to configure it. (We use a hacky js script)
2. Versioning and publishing packages automatically, lerna had a thing for this too but it isn't great. When a single package goes to `v0.1` not all packages have to go to `v0.1`

Would love to hear others solution to these and I can update this space with them

## Conclusion

Unfortunately, monorepos are still kinda weird and complicated but I hope I gave you some of the tooling we use to make it easier. I also apologise if this felt a bit disorganized but it is a result of we came up with this structure with many many iterations and if we started new it probably would be a bit cleaner.

{% github cryogenicplanet/typescript-monorepo-example no-readme %}

Finally if you are at all interested in video or video editing come checkout [modfy.video](http://modfy.video)

You can also find the most upto date version of this post on https://cryogenicplanet.tech/posts/typescript-monorepo