How to set up a TypeScript Monorepo

At Escape, we write our software in TypeScript because it allows us to iterate quickly without compromising on safety. While our codebase evolves a lot as we develop new features, its structure has been stable for a while now. This article aims to explain our current setup, how to put it in place and the developer experience it offers.

Understanding the Benefits of a TypeScript Monorepo

TypeScript, a statically-typed super-set of JavaScript, allows you to catch errors early and provide robust type checking. If you're a developer or a team managing multiple TypeScript projects, there's a good chance you've dealt with the hassles of project setup, code sharing, and consistency across apps. This is where a TypeScript Monorepo comes in. A "monorepo" means that you keep all your projects, even those written in different languages, under a single repository. This approach has the dual benefit of reducing setup complexity while facilitating code sharing and uniformity across projects.

To delve deeper into this, a Monorepo structure provides additional benefits:

  • Shared Code: One of the main advantages of a Monorepo setup is the ability to share code across multiple projects. This promotes code reusability and consistency across the applications.
  • Single Build Configuration: With a Monorepo, there's no need to duplicate build, test, or deployment configurations for each project. You can manage these configurations from a single place.
  • Improved Developer Experience: Monorepo structure provides developers with a unified view of the codebase, making it easier to understand the interdependencies and collaborate more effectively. Also, you can change, test, and deploy your applications in a coordinated manner.
  • Simplified Dependency Management: In a monorepo, all projects use the same version of dependencies. This ensures a consistent developing environment, avoiding potential conflicts or bugs that could arise from mismatched dependency versions.

In essence, a TypeScript Monorepo is an excellent tool for a development team to streamline their workflows, maintain a high code quality, and keep a better handle on the project's overall complexity. Now, let's look at how to set up a TypeScript Monorepo.

Setting up a TypeScript Monorepo

Choosing the right tools

Setting up a TypeScript Monorepo might seem daunting initially, but it's straightforward once you understand the steps involved. The following guide will enable you to go through the process smoothly.

To keep this article concise, we will only setup a subset of all the tooling we use in our monorepo. We will focus on:

  • Yarn, a package manager and monorepo task runner
  • TypeScript, a superset of JavaScript with types
  • Tsx, a transparent TypeScript compiler that does all the heavy lifting in our monorepo
  • Docker, an image builder and container runtime

The goal is to make development delightful as well as to build a performant Docker image for production:

  • We want to run TypeScript directly during development
  • We want a reliable watch mode
  • And we want these features for dependencies too – which is the hard part

Structure

We can quickly scaffold a Node.js monorepo with Yarn 4, currently RC at this time of writing. We will use Yarn 4 because it ships with great monorepo tools:

# Create a new directory
mkdir typescript-monorepo && cd $_

# Create a Node.js workspace with Yarn 4
yarn init -2 -pw && yarn set version canary

# Use the good ol' node_modules
yarn config set nodeLinker node-modules

# Ignore artifacts
echo "build/\nnode_modules/" >> .gitignore

# Commit
git add . && git commit -m "chore: setup monorepo"
📦
Running these commands requires a global installation of Node.js LTS (18.17 as of now) and Yarn classic (1.19). You can quickly have them running using Volta.

We are ready to go, let's write some code now! Our monorepo will only contain two packages:

  • An API responding Hello World! to all requests
  • A logger that prints things in the console with a timestamp, used by the API

Running in development

Let's start with the logger. packages/logger/index.ts will be this simple file:

/** Logs something in the console. */
export default (...args: any[]) =>
  console.log("[" + new Date().toISOString() + "]", ...args);

It would be hard to make a simpler logger. To make it a complete TypeScript package, we need two additional files: a package.json and a tsconfig.json. Here comes the configuration magic!

// package.json
{
  "name": "logger",
  "devDependencies": {
    "typescript": "^5.1.6"
  },
  "exports": { // 🪄 Expose different things depending on context
    "types": "./index.ts",
    "development": "./index.ts",
    "default": "./build/index.js"
  },
  "private": true,
  "scripts": {
    "build": "tsc" // `yarn build` will transform TS into JS
  },
  "type": "module" // Tell Node.js that we write ESM
}

We expose our TypeScript source directly for type-checking and development, allowing us to run TypeScript code directly instead of transpiled code. This is how we achieve watch mode for dependencies too.

The tsconfig.json file will stay short, only specifying what we need:

// tsconfig.json
{
  "compilerOptions": {
    "moduleResolution": "NodeNext",
    "outDir": "build",
    "strict": true,
    "target": "ESNext"
  }
}

We cannot do anything at this point, the logger is meant to be used by other packages.  Let's create a simple Hello World API in packages/hello-api/index.ts:

import express from "express";
import log from "logger";

const app = express();

app.use((req, res) => {
  log("Received request for URL:", req.url);
  res.end("Hello World!");
});

app.listen(3000, () => {
  log("The server is running on port 3000!");
});

How do we get this thing working? Same as before, we need two additional config files:

// package.json
{
  "name": "hello-api",
  "dependencies": {
    "express": "^4.18.2",
    "logger": "workspace:^" // Use the logger from the monorepo
  },
  "devDependencies": {
    "@types/express": "^4.17.17",
    "tsx": "^3.12.7",
    "typescript": "^5.1.6"
  },
  "private": true,
  "scripts": {
    "build": "tsc", // Build for production
    "dev": "tsx --watch --conditions=development index.ts", // Watch in dev
    "start": "node build/index.js" // Run production build
  },
  "type": "module"
}
// tsconfig.json
{
  "compilerOptions": {
    "moduleResolution": "NodeNext",
    "outDir": "build",
    "strict": true,
    "target": "ESNext"
  }
}

All the magic is contained within a single command: tsx --watch --conditions=development index.ts.

That is a lot of features in a single command! Recent Node versions have added a lot of features such as a watch mode, making nodemon obsolete.

You can try running the server with yarn install && yarn workspace hello-api dev and you'll be greeted by a log message with a timestamp. Want a more natural timestamp? Replace toISOString() with toLocaleString() and the API will restart instantly, no more need to transpile dependencies.

Furthermore, the VSCode JavaScript debug terminal will work right away, allowing you to place breakpoints directly into TypeScript files, even for dependencies. That is the developer experience you didn't know you needed.

Running in production

There is a high chance you are running your Node.js applications in Docker containers in production. This is no easy task now that packages in your repository can become inter-dependent. Here is how we do it at Escape.

  • Add a build script in the root package.json: yarn workspaces foreach --topological-dev -pi run build. This will have yarn run the build step of all packages in the right order, parallelly if possible.
  • Add a single Dockerfile at the root; we will build a single image containing all services. This will definitely improve your build and upload times if you currently build one image per exposed service, as well as reduce the maintenance costs.
FROM node:lts
WORKDIR /app/
COPY . .

RUN \
  # Install all dependencies
  yarn install --immutable &&\
  # Build all packages
  yarn build &&\
  # Remove dev dependencies
  yarn workspaces focus --production --all

It has no default entrypoint, as it may contain several. Use the following command to build and start your image:

# Build a Docker image
docker build . -t app:latest

# Start the API on port 3000
docker run -it -p 3000:3000 app:latest yarn workspace hello-api start

You are completely free to start any other service as long as it is in your monorepo.

Next steps: embracing the power of a TypeScript Monorepo

You now have the bare minimal to write and start services, but there still a lot of things you can do:

  • Setup code quality tools such as prettier and eslint
  • Setup a build tool with cache such as turbo
  • Setup automated image build in a CI

All these tools should live at the root of your monorepo, ensuring consistency of all the projects that live within.

This concludes this article, we hope you enjoyed it and found it insightful. Feel free to share comments where you found it, we look forward to reading from you.

Want to learn more about monorepos?

Check out the following articles on our how we use Monorepos at Escape: