Article
Technical
An actual complete guide to typescript monorepos
6/2/2021
··
profile
 
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
 
We can start by setting up our package.json
Feel 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 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
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

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 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:
  • The only tsc command you have is tsc --build which is typescript's multistage build
 
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 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
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/*/lib
 

Testing (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:
 
  1. Typescript only packages, that is packages that don't need to be deployed with javascript support. Examples, interfaces, or internal only packages
  1. Packages that depend on other packages
  1. Packages with testing
  1. Packages with build steps
  1. 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.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/
  1. 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
  1. 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

 
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 file
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') })
 
Tsup
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" } }
// 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

  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)
  1. 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