Skip to Content
Course content

67: Configuring TypeScript for Monorepos with Turborepo or Nx

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was helping a teammate—let's call him Marcus—who was migrating a massive frontend project into a monorepo using Turborepo. He had a shared-ui package and a marketing-site app. Everything seemed fine until he changed a single interface in shared-ui. Suddenly, his IDE went nuclear. The marketing-site was screaming about type mismatches, but the build tool said everything was cached and up-to-date. He spent half a day deleting node_modules and restarting his editor, only to realize that TypeScript was looking at a stale build artifact of the shared library instead of the actual source code.

This is the "monorepo ghost" problem. When you move from a single project to a monorepo, you aren't just managing more code; you're managing the relationships between projects. If you just treat every package as a standalone TypeScript project, you'll either end up with glacial compile times (because TS is re-checking the whole world) or you'll deal with the stale-type madness Marcus experienced.

Taming the Graph with Project References

The secret to making this work is a feature called Project References. In a standard setup, you have one tsconfig.json. In a monorepo, you need a hierarchy. By setting "composite": true in your package's tsconfig.json, you're telling TypeScript that this project is intended to be a dependency for other projects.

But composite is only half the battle. The real magic happens in the consuming project. Instead of just hoping the types are there, you explicitly list your dependencies in a references array. It looks like this:

{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "composite": true
  },
  "references": [
    { "path": "../shared-utils" }
  ]
}

I can't stress this enough: this is what allows tsc --build (or tsc -b) to work its magic. Instead of blindly compiling everything, TypeScript looks at the graph. If shared-utils hasn't changed, TS uses the pre-compiled .d.ts files. If it has changed, it rebuilds only what's necessary. It transforms your build process from a sledgehammer into a scalpel.

Syncing TypeScript with Turborepo and Nx

Now, here is where people usually get tripped up. Project References handle the type checking, but Turborepo or Nx handle the task execution. They don't automatically know about your TypeScript references.

If you're using Turborepo, you need to ensure your turbo.json pipeline reflects the dependency graph. If your build task depends on the build task of your dependencies, Turbo will ensure that shared-utils is actually compiled to disk before marketing-site tries to run its own build. I've seen developers try to bypass this by using paths in tsconfig.json to point directly to .ts source files. While that feels faster in the IDE, it often leads to nightmare scenarios in CI/CD where the production build fails because it's trying to import source files that aren't actually present in the final bundle.

With Nx, this is often handled more transparently through their plugin system, but the underlying principle remains: your TypeScript configuration must be a mirror of your build tool's dependency graph. If you tell Nx that Project A depends on Project B, your tsconfig.json for Project A should have a project reference to Project B. If they get out of sync, you're back to Marcus's world of deleting node_modules and praying to the compiler gods.

Handling Global Types and Base Configs

You don't want to repeat 50 lines of compiler options in every single package. The professional way to handle this is through a "base" config. Create a tsconfig.base.json at the root of your monorepo containing your strictness settings, target ES version, and module resolution logic.

Each package then simply extends that base:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "composite": true
  },
  "references": [{ "path": "../core" }]
}

This keeps your configuration DRY and ensures that if you decide to flip strictNullChecks to true, you do it in one place rather than hunting through twelve different folders. It's a small organizational win that prevents massive headaches as your team grows.




📋 Practical Task

Exercise: Resolving a Broken Dependency Chain in a Monorepo

You have been handed a small Turborepo project with two packages: @workspace/core (containing shared types) and @workspace/api (the server). Currently, the project is using paths aliases, which is causing the api build to fail in the CI environment because it cannot find the source files of core during the production build.

Your Goal: Convert the setup from "Path Aliasing" to "TypeScript Project References".

  • Step 1: In packages/core/tsconfig.json, enable composite: true and ensure an outDir is specified.
  • Step 2: In packages/api/tsconfig.json, remove the paths mapping that points to ../core/src.
  • Step 3: In packages/api/tsconfig.json, add a references array that points to the ../core directory.
  • Step 4: Run npx tsc -b packages/api from the root and verify that TypeScript successfully triggers a build of core before validating api.

Success Criteria: The api package should compile successfully using the -b (build) flag without relying on paths aliases, proving that the Project Reference graph is correctly configured.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.