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.
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)
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 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
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
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 istsc --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
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
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
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/'
}
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
},
}
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.
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" }]
}
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
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 forreact/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 --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. swc-project/swc#657 (comment)
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.
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.
- 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.1
not all packages have to go tov0.1
Would love to hear others solution to these and I can update this space with them
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