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.jsonWe can start by setting up our 
package.jsonFeel free to swap 
pnpm out with yarn// package.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
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 toolThese are definitely not all the dependencies but others will be based on your config
Finally your 
.gitignore can look like thisnode_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 likepackages:
  # 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
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"
}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 repositorySetting 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:
- The only tsccommand you have istsc --buildwhich is typescript's multistage build
- You also have to use the compositeflag which has a few added requirements 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
// 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
pnpm add -DW @types/node typescriptEslint + 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// .prettierrc
"prettier-config-standard"// .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/*/libTesting (Optional)
Here's a configuration for basic testing with jest 
pnpm add -DW jest ts-jest @types/jest tsconfig-paths-jest // 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
}// 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:
- Typescript only packages, that is packages that don't need to be deployed with javascript support. Examples, interfaces, or internal only packages
- Packages that depend on other packages
- Packages with testing
- Packages with build steps
- 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 For the 
tsconfig.json it should be structured like// 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// 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// 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{
  "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// 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
// 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
//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.jsonPackages 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
- esbuild- I cannot stress how awesome esbuild is, its really great and fairly easy to get started with https://esbuild.github.io/
- 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
- 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 --buildIf 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-585652262Base configurations
Esbuild
// package.json
{
"scripts" : {
	"build" : "esbuild src/index.ts --bundle --platform=node --outfile=lib/index.js"
	"postbuild" : "tsc --build"
	}
}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'
        }
      }
    }
  }
})// 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 fileconst 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')
})Tsup 
Tsup is the easiest and just that 
tsup src/* --env.NODE_ENV production --dts-resolveThe 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"
    }
} // 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
{
	"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
- 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)
- Versioning and publishing packages automatically, lerna had a thing for this too but it isn't great. When a single package goes to v0.1not all packages have to go tov0.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.
Here is an example repo: https://github.com/CryogenicPlanet/typescript-monorepo-example
Finally if you are at all interested in video or video editing come checkout modfy.video
