Guide to painless TypeScript monorepos with esbuild

Adel Nizamutdinov

Adel Nizamutdinov

Oct 5, 2021

TL;DR

The trick to fast builds is to avoid certain tools and replace them with others:

  • Avoid TypeScript Compiler, jest, tools like ts-node & ts-jest, ES Modules, TypeScript project references.
  • Use esbuild, uvu, source code folders, TypeScript type checker.

Project layout

We'll be using yarn workspaces to organize packages but both npm and pnpm have them too.

Root package.json will look like:

{
  "private": true,
  "name": "monorepo",
  "workspaces": [
    "shared",
    "back",
    "front",
    "iconsearch"
  ],
  "devDependencies": {
    "esbuild": "^0.13.2",
    "typescript": "^4.4.3"
  }
}
Monorepo diagram
Diagram created in Arrowbox

In this post iconsearch is going to be an example of a Cloudflare Worker.

TypeScript

Best approach to tsc (TypeScript Compiler) is to avoid it as much as possible.

Define tsconfig.base.json with defaults for all subpackages:

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "downlevelIteration": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "jsx": "preserve",
    "moduleResolution": "Node",
    "noEmit": true,
    "noImplicitAny": true,
    "noImplicitThis": true,
    "skipLibCheck": true,
    "strict": true
  }
}

Because tsc is very slow, it should only be run for checking types, hence noEmit: true. For outputting JavaScript it's best to use esbuild, which is many times faster than tsc.

The shared package

This is the reason we're going with the monorepo in the first place. The standart TypeScript approach is to use project references and add the shared package in app dependencies. But this approach has many issues with recompilation and testing that we're just going to use it as a source folder.

Define simple shared/package.json:

{
  "name": "shared",
  "private": true,
  "version": "0.0.1",
  "scripts": {
    "typecheck": "tsc"
  }
}

And shared/tsconfig.json:

{
  "compilerOptions": {
    "lib": ["ESNext"]
  },
  "extends": "../tsconfig.base.json",
  "include": ["src/"]
}

We don't build the shared package, only test and lint it. Skip to testing or linting.

Importing your code in the apps will look like this:

import { exportedThing } from "shared/src/file"

Next.js

To make Next.js work with shared dir, add externalDir to front/next.config.js:

module.exports = {
  experimental: {
    externalDir: true,
  },
  eslint: {
    dirs: ["src/"]
  },
}

and include "../shared/src/" in front/tsconfig.json:

{
  "compilerOptions": {
    "allowJs": true,
    "isolatedModules": true,
    "lib": [
      "DOM",
      "DOM.Iterable",
      "ESNext"
    ],
    "module": "ESNext",
    "resolveJsonModule": true,
    "target": "ES5"
  },
  "extends": "../tsconfig.base.json",
  "include": [
    "src/",
    "../shared/src/"
  ]
}

And that's it. next binary does everything for us (even adjusts tsconfig.json).

Skip to testing or linting.

Node.js

ES Modules

Do not "type": "module" unless you know what you're doing. And even then think twice if you want to waste time getting frustrated at your computer.

Config

Include ../shared/src/ in back/tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react",
    "lib": ["ESNext"],
    "module": "CommonJS",
    "target": "ES2020"
  },
  "extends": "../tsconfig.base.json",
  "include": [
    "src/",
    "../shared/src/"
  ]
}

We also need to config some things related to dotenv and source maps in back/package.json:

{
  "private": true,
  "name": "back",
  "version": "0.0.1",
  "main": "dist/server.js",
  "dependencies": {
    "dotenv": "^10.0.0",
    "source-map-support": "^0.5.20"
  },
  "scripts": {
    "dev": "node dev.js",
    "build": "node build.js",
    "start": "DOTENV_CONFIG_PATH=.env.development node -r dotenv/config -r source-map-support/register .",
    "typecheck": "tsc"
  }
}

We're using source-map-support because --enable-source-maps is both slow and experimental (around 4s slower server start).

Building

A simple script to transpile TypeScript ready to run on Node.js 14.

build.js:

const fs = require('fs')
const esbuild = require('esbuild')

const shared = JSON.parse(fs.readFileSync("../shared/package.json", "utf-8"))
const back = JSON.parse(fs.readFileSync("./package.json", "utf-8"))

// pure ESM packages that also have to be transpiled
const esms = new Set(["d3-shape", "node-fetch"])
const external = [...Object.keys(shared.dependencies), ...Object.keys(back.dependencies)]
    .filter(x => !esms.has(x))

esbuild.buildSync({
  bundle: true,
  entryPoints: ['src/server.ts'],
  external,
  logLevel: "info",
  minify: true,
  outfile: 'dist/server.js',
  platform: 'node',
  sourcemap: true,
  target: ['node14'],
  treeShaking: true,
})

It also handles pure ESM packages which is a great bonus. It is very fast.

Development

esbuild has a watch mode and you can launch your node server on rebuild.

dev.js:

const fs = require('fs')
const esbuild = require('esbuild')
const childProcess = require('child_process')

const shared = JSON.parse(fs.readFileSync("../shared/package.json", "utf-8"))
const back = JSON.parse(fs.readFileSync("./package.json", "utf-8"))

// pure ESM packages that also have to be transpiled
const esms = new Set(["d3-shape", "node-fetch"])
const external = [...Object.keys(shared.dependencies), ...Object.keys(back.dependencies)]
    .filter(x => !esms.has(x))

let proc

const onRebuild = () => {
  proc?.kill()
  proc = childProcess.spawn("yarn", ["start"], {
    stdio: "inherit",
    stderr: "inherit",
    detached: false,
  })
}

esbuild.build({
  bundle: true,
  entryPoints: ['src/server.ts'],
  external,
  logLevel: "info",
  minify: true,
  outfile: 'dist/server.js',
  platform: 'node',
  sourcemap: true,
  target: ['node14'],
  watch: { onRebuild },
}).then(onRebuild)

(Optional) Cloudflare Worker

Super easy! Define iconsearch/package.json:

{
  "private": true,
  "name": "iconsearch",
  "version": "0.0.1",
  "main": "dist/index.js",
  "devDependencies": {
    "@cloudflare/workers-types": "^2.0.0",
    "@cloudflare/wrangler": "^1.11.0"
  },
  "scripts": {
    "build": "esbuild src/index.ts --bundle --outfile=dist/index.js",
    "deploy": "wrangler publish",
    "dev": "wrangler dev --env staging",
    "logs": "wrangler tail --format pretty",
    "typecheck": "tsc"
  }
}

Things to note here is main and scripts.build, both of them point to dist/index.js.

iconsearch/tsconfig.json:

{
  "compilerOptions": {
    "lib": [
      "ES2019",
      "WebWorker"
    ],
    "target": "ES2019",
    "types": ["@cloudflare/workers-types"]
  },
  "extends": "../tsconfig.base.json",
  "include": ["src/"]
}

This is it, esbuild is very good at bundling for CF Workers.

Testing

TypeScript tests are even slower than builds: with tsc you have to compile your working code, dependencies and test code. Then you get an extra slowdown for using jest.

We'll use uvu with esbuild-register. uvu is an extremely fast test runner and esbuild-register is a node hook that just strips TypeScript types on the fly.

# run in project root
yarn add uvu esbuild-register -D -W

Then define your test script in any package.json:

# tests in test/
uvu -r esbuild-register test/

# tests in src/**/__tests__/*.test.ts
uvu -r esbuild-register src/ '.*.test.ts'

# tests in src/**/__tests__/*.test.ts with dotenv
DOTENV_CONFIG_PATH=.env.test uvu -r esbuild-register -r dotenv/config src/ '.*.test.ts'

With uvu we win both in speed and amount of config. There's no test runner magic and each uvu test file can be run as a regular node script (don't forget -r esbuild-register).

It also has its downsides:

Jest

If you must stay on jest, there's an esbuild-jest package that might speed things up, but I haven't used it myself.

Linting

At this point I haven't found anything faster than eslint, so I just use it.

# run in project root
yarn add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser -D -W

Define an .eslintrc.js similar to this in subpackages you want to lint:

module.exports = {
  extends: [
    "eslint:recommended",
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: {
    project: "./tsconfig.json",
  },
  plugins: [
    '@typescript-eslint',
  ],
  env: {
    node: true,
    browser: true,
  },
  rules: {
    "eqeqeq": ["error", "always"],
    "no-console": "error",
    "@typescript-eslint/no-for-in-array": "error",
    "@typescript-eslint/no-inferrable-types": "warn",
    "@typescript-eslint/ban-ts-comment": ["error", {
      'ts-expect-error': true,
      'ts-ignore': 'allow-with-description',
      'ts-nocheck': true,
      'ts-check': 'allow-with-description',
      "minimumDescriptionLength": 1,
    }],
    "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
    "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: true }],
    "@typescript-eslint/no-unsafe-assignment": "warn",
    "@typescript-eslint/no-unsafe-return": "warn",
    "@typescript-eslint/adjacent-overload-signatures": "warn",
    "@typescript-eslint/restrict-template-expressions": "warn",
  },
}

then just add this to package.json of the folders you want to lint

{
  "scripts": {
    "lint": "eslint ."
  }
}

In conclusion

This setup gave me a massive boost in speed and configuration reduction. Configuring and running jest with ts-jest and ES Modules was such a pain in the butt. And my backend tests run 6x faster now (2.1s down from 13s).

I'm also looking forward to what swc will provide in terms of speed and configuration, both as part of Next.js and standalone. Fast tools make work so much more enjoyable for me, hope you'll enjoy it too.

Ready to dive in?Start your free trial today.

Live demo