Guide to painless TypeScript monorepos with esbuild
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"
}
}
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
).
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:
- no code coverage support
- no editor support (please upvote this WebStorm issue if you can)
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 . --fix"
}
}
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.