DEV Community

Cover image for An actual complete guide to typescript monorepos
Rahul Tarak
Rahul Tarak

Posted on • Updated on • Originally published at cryogenicplanet.tech

An actual complete guide to typescript monorepos

When I was setting up our production monorepo at 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

.
├── 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
Enter fullscreen mode Exit fullscreen mode

We can start by setting up our package.json

Feel free to swap pnpm out with yarn

{
  "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/*"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Some basic dependencies we can install are

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

Setting up workspace

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

packages:
  # all packages in subdirs of packages/ and components/
  - 'packages/**'
  # exclude packages that are inside test directories
  - '!**/test/**'
  - '!**/__tests__/**'
Enter fullscreen mode Exit fullscreen mode

Full documentation can be found here https://pnpm.io/workspaces

Orchestration

There are a few options for orchestration tools you can use like rushjs 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

{
  "packages": ["packages/*"],
  "npmClient": "yarn",
  "useWorkspaces": true,
  "version": "0.0.1"
}
Enter fullscreen mode Exit fullscreen mode

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

Few things to not about it are:

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

// 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/" },
  ]
}
Enter fullscreen mode Exit fullscreen mode

Finally it is good to add these dependencies as global dependencies

pnpm add -DW @types/node typescript
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode
// .prettierrc
"prettier-config-standard"
Enter fullscreen mode Exit fullscreen mode
// .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
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode
// .eslintignore
*/**.js
*/**.d.ts
packages/*/dist
packages/*/lib
Enter fullscreen mode Exit fullscreen mode

Testing (Optional)

Here's a configuration for basic testing with jest

pnpm add -DW jest ts-jest @types/jest tsconfig-paths-jest 
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode
// jest.config.js
const base = require('./jest.config.base.js')

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

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

├── package.json // can be a standard package.json
├── README.md // can be whatever
├── src
│   ├── index.ts
├── tsconfig.json 
Enter fullscreen mode Exit fullscreen mode

For the tsconfig.json it should be structured like

// tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist", // Your outDir
  },
  "include": ["./src"]
}
Enter fullscreen mode Exit fullscreen mode

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

// 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
  },
}
Enter fullscreen mode Exit fullscreen mode

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

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

Second in package-a we reference this package like

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

Packages with test

For packages that are using jest for testing

// 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
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

//tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "exclude": ["**/*.spec.ts", "**/*.test.ts"]
}
Enter fullscreen mode Exit fullscreen mode

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/
  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

  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

Base configurations

  1. esbuild

    // 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

    // 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",
}
}
```
Enter fullscreen mode Exit fullscreen mode
For vite specifically we need to bundle all the `.d.ts` files into a single declaration file
Enter fullscreen mode Exit fullscreen mode
```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')
})
```
Enter fullscreen mode Exit fullscreen mode
  1. 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

// package.json
{
    "name": "foo",
    "version": "1.0.0",
    "main": "src/index.ts",
    "publishConfig": {
        "main": "lib/index.js",
        "typings": "lib/index.d.ts"
    }
} 
Enter fullscreen mode Exit fullscreen mode
// will be published as 
{
    "name": "foo",
    "version": "1.0.0",
    "main": "lib/index.js",
    "typings": "lib/index.d.ts"
}
Enter fullscreen mode Exit fullscreen mode

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

{
    "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/*"
      ]
}
Enter fullscreen mode Exit fullscreen mode

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.

Finally if you are at all interested in video or video editing come checkout modfy.video

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

Top comments (6)

Collapse
 
pitops profile image
Petros Kyriakou

Interesting approach thanks for sharing.

Here is a scenario that i have trouble with:
run react app
(react app has dependency package-a which has dependency interfaces package)

when i update the interface e.g adding something extra to the Example interface

vite will not detect the change and won't hot reload. Any ideas why?

Collapse
 
andreychernykh profile image
Andrei Chernykh

A few notes:

  • I believe workspaces in package.json is redundant - we define workspaces in pnpm-workspace.yaml

  • There are typos for -DW key in few places. It should be -Dw

Collapse
 
gargantulakon profile image
GargantulaKon • Edited

When you mentioned, "Finally it is good to add these dependencies as global dependencies" the command you showed didn't inlcude the --global flag. Was it supposed to? Do we really need to install it globally to get this to work?

And when you mentioned, "Similar to the about workspace file we need a lerna.json where we set the packages", shouldn't the lerna.json have pnpm as as npmClient?

In addition, after I set it up how to run and use it? I am trying to override our node module that we are importing in the main project with the local code instead. Maybe I am going about it in the wrong way.

Collapse
 
cryogenicplanet profile image
Rahul Tarak
  1. Technically no you don't need to install anything globally that is not used globally. Let's say only one package uses react, no harm in installing it only there. Or if used in 2 out of 15 packages, you can installed only in those two. It does become more convenient to install it globally if it is used everywhere or most places tho.
    Note here, things like eslint rules that are used at the top level need to be installed there

  2. Lerna doesn't actually support pnpm and we aren't using Lerna for any package installation so it doesn't matter. Actually there is an issue on the repo, showing how we can avoid Lerna and use use pnpm recursive(which I didn't know off at the time)

  3. Not really sure what the question is here? You use it like you'd use any mono repo, you can build and run the packages that need to be built and run. The others are used for codesharing

Collapse
 
allanshady profile image
Allan Camilo

Amazing 😍. Thanks!

Collapse
 
boscodomingo profile image
Bosco Domingo • Edited

You're missing deployment. How can you call this "the actual complete guide" if you're missing the most crucial step of actually delivering value with code!