This is an alpha, sneak peek of Monorepo Maestros. For this iteration, I'm getting all of my thoughts down. In the future, we'll have better information architecture, graphics, and other awesomeness. Your feedback is welcome!

TypeScript

Handling TypeScript in a monorepo can be daunting at first. If you set it up wrong, you can end up frustrated with errors that don't make sense and slow type checking scripts.

But, once you've learned to hold the TypeScript violin just right, the type checking in your repo will be fast and you can iterate safely.

Let's build a simple TypeScript package to find out how this works.

Create a workspace for a common tsconfig.json

First, we'll build a TSConfig that will be the base for all the TypeScript code in our repository. We'll always extend off of this base to reduce duplication and know we're working with the right set of defaults every time.

Create a workspace in your tooling directory (or packages, whatever you prefer). We'll add a package.json that exports a base.json file:

tooling/tsconfig/package.json
{
"name": "@repo/tsconfig",
"version": "0.0.0",
"private": true,
"files": ["base.json"]
}
tooling/tsconfig/package.json
{
"name": "@repo/tsconfig",
"version": "0.0.0",
"private": true,
"files": ["base.json"]
}

Note that we don't need to install TypeScript (or anything else!) in this package's dependencies. All we need is a base.json TSConfig like this one:

tooling/tsconfig/base.json
{
"$schema": "https://json.schemastore.org/tsconfig", // For in-editor Intellisense
"display": "Default",
"compilerOptions": {
"incremental": true, // Must have for next steps!
"skipLibCheck": true, // Highly recommended!
"strict": true, // Highly recommended!
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "Node16",
"noImplicitAny": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true
},
"exclude": ["node_modules"]
}
tooling/tsconfig/base.json
{
"$schema": "https://json.schemastore.org/tsconfig", // For in-editor Intellisense
"display": "Default",
"compilerOptions": {
"incremental": true, // Must have for next steps!
"skipLibCheck": true, // Highly recommended!
"strict": true, // Highly recommended!
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "Node16",
"noImplicitAny": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true
},
"exclude": ["node_modules"]
}

The TSConfig above has the usual defaults for most projects so feel free to copy-paste. It contains many optional properties that you can tailor it to your liking but, at the very least, make sure you keep:

Make the TypeScript compiler fast in a monorepo

There are three strategies that we will use to make tsc fast:

  1. incremental saves information about your project compilation into a file. The first time you run tsc, the compiler will create this file to make subsequent runs faster.
  2. Parallelizing the type checking scripts in our workspaces.
  3. Caching workspace tasks with our package manager so that we can ensure we never do the same work twice.

Putting these techniques together, we'll be caching the type check task for entire workspaces so we hit cache for work we've already done and be as fast as possible when we miss the cache to check types.

Set up a TypeScript workspace

Let's start by handling the package.json for our workspace. We'll need to install:

  1. typescript
  2. @repo/tsconfig, the package with our base TSConfig.

After that we'll create a script for running tsc with the --noEmit flag so that the compiler only checks types, skipping out on the actual compilation work.

packages/logger/package.json
{
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/tsconfig": "workspace:*" // Use "*" for npm or yarn
},
"devDependencies": {
"typescript": "5.1.6"
}
}
packages/logger/package.json
{
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/tsconfig": "workspace:*" // Use "*" for npm or yarn
},
"devDependencies": {
"typescript": "5.1.6"
}
}

Next, we'll create a tsconfig.json in our workspace where we'll extend from our base configuration:

packages/logger/tsconfig.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@repo/tsconfig/base.json", // Using our base config!
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}
packages/logger/tsconfig.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@repo/tsconfig/base.json", // Using our base config!
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

Most of this file probably looks familiar. We're extending from our base TSConfig - but what's this tsBuildInfoFile property?

tsBuildInfoFile tells the TypeScript compiler where to store the output of incremental. We're going to use the .cache folder in node_modules as this is a common practice for storing caches for libraries and dependencies.

Test it out! (Using globally installed turbo for simplicity here)

Terminal
cd packages/logger
turbo typecheck
Terminal
cd packages/logger
turbo typecheck

If all goes according to plan, you'll see your task ran by Turborepo in your terminal and a tsbuildinfo.json file in the node_modules/.cache directory of your workspace.

Build a Turborepo pipeline

Now, we'll build our Turborepo pipeline with two important characteristics.

turbo.json
{
"pipeline": {
"topo": {
"dependsOn": ["^topo"]
},
"typecheck": {
"dependsOn": ["topo"],
"outputs": ["node_modules/.cache/tsbuildinfo.json"]
}
}
}
turbo.json
{
"pipeline": {
"topo": {
"dependsOn": ["^topo"]
},
"typecheck": {
"dependsOn": ["topo"],
"outputs": ["node_modules/.cache/tsbuildinfo.json"]
}
}
}

With these two optimizations, we've massively sped up the type checking task in your repository. We also have a base TSConfig that we will always use so our repository has uniformity that we can trust between individuals and teams.

Congratulations, you are now friends with TypeScript.

Good to know:
  • You can create multiple files in your TSConfig workspace to extend from. You may want to do this if you have specific needs (for instance, a certain framework requires certain options). If you do create multiple files, you may still find it advantageous to extend from a common base configuration in those custom configurations..

  • You'll notice I didn't mention TypeScript's Project References. In a Turborepo, you can usually avoid them. 😁