35.5 Nx: Integrated TypeScript Monorepo Tooling
Right, so you’ve decided to manage the beautiful chaos of a monorepo. Good for you. It’s the only sane way to build a system out of interconnected parts without losing your mind to ../../../../ hell. But TypeScript, brilliant as it is, wasn’t originally designed with this multi-project, cross-reference madness in mind. You can cobble it together yourself with a mountain of tsconfig.json files and a prayer, but you’ll spend more time nursing the build system than writing code. This is where Nx comes in—not just as a task runner, but as a full-fledged build system that understands your project graph and, crucially, understands TypeScript.
Nx’s first genius move is replacing the stock tsc compiler with its own TypeScript compiler plugin. This isn’t just a swap for fun; it’s a fundamental architectural shift. Instead of running tsc in each project individually and hoping the symlinks line up, Nx uses this plugin to create a single program—a unified view—of all the TypeScript in your workspace. It knows that @my-monorepo/api depends on @my-monorepo/utils, so it can build them in the correct order and understand the types across the entire codebase instantly. The result? Blazing fast builds and, more importantly, truly integrated type checking.
The Project Configuration: project.json
Forget what you know about a basic package.json script runner. Nx uses a project.json file for each project to define its targets (like build, test, lint) and, critically, its implicit dependencies. This is how Nx builds its project graph.
// apps/my-app/project.json
{
"name": "my-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/my-app/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nrwl/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/my-app",
"main": "apps/my-app/src/main.ts",
"tsConfig": "apps/my-app/tsconfig.app.json",
"assets": ["apps/my-app/src/assets"]
},
"dependsOn": ["build"]
}
}
}
See that "dependsOn": ["build"]? That’s you explicitly telling Nx, “Hey, before you build my-app, make darn sure you’ve built the my-lib library first.” Nx can often infer this, but being explicit is never a bad idea.
The TypeScript Config: It’s Graphs All the Way Down
Your root tsconfig.json sets the base compiler options. Then each project gets at least two configs: one for the application itself (tsconfig.app.json) and one for type checking (tsconfig.lib.json or tsconfig.spec.json). The magic is in the extends and the references that wire everything together using the TypeScript Project References feature, which Nx manages for you.
// apps/my-app/tsconfig.app.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
},
"files": ["../../node_modules/nx/typescript/types.d.ts"],
"include": ["**/*.ts"],
"exclude": ["**/*.spec.ts"]
}
And for your library:
// libs/my-lib/tsconfig.lib.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": []
},
"files": ["../../node_modules/nx/typescript/types.d.ts"],
"include": ["**/*.ts"],
"exclude": ["**/*.spec.ts"]
}
Notice the "files": ["../../node_modules/nx/typescript/types.d.ts"] line. That’s Nx injecting its own type definitions to make its magic work. Don’t delete it. You’ll break the spell and the Nx wizard will be very cross.
The Pitfall: Paths vs. Dependencies
Here’s the most common “I’m pulling my hair out” moment in Nx monorepos. You’ve set up path mapping in your root tsconfig.json:
// tsconfig.base.json
{
"compilerOptions": {
"paths": {
"@my-monorepo/my-lib": ["libs/my-lib/src/index.ts"]
}
}
}
This makes your IDE think it can resolve the import import { stuff } from '@my-monorepo/my-lib'; and it will happily autocomplete. But then you run nx build my-app and it fails spectacularly. Why? Because the path mapping is only for the TypeScript compiler and your IDE. It is not a package manager.
Nx needs to understand the dependency in its graph. You must have the library listed in your application’s package.json dependencies. Nx uses this to know what to build and how to link the outputs.
// apps/my-app/package.json
{
"name": "@my-monorepo/my-app",
"dependencies": {
"@my-monorepo/my-lib": "*" // This is non-negotiable.
}
}
The "*" is fine because Nx isn’t actually using npm to fetch the package; it’s using its own internal mechanism to link to the built version of the library in the dist folder. This tripped me up for a solid hour once. Learn from my pain.
The Power Move: Computation Caching
This is the killer feature. Run nx build my-app. Now run it again. See that > NX cache hit message? Nx hashes the inputs—the source files, the config files, the environment variables you tell it to care about—and if nothing has changed, it just replays the output from the last run. This isn’t just for builds; it’s for tests, linting, anything. In a CI pipeline, this is the difference between a 20-minute build and a 20-second one. It’s the reason you adopt Nx.
The best practice here is to be meticulous with your outputs configuration in your project.json. Tell Nx exactly where a target writes its files so it can reliably restore them. If a target has side effects that aren’t captured in the output directory (which it shouldn’t, but let’s be honest), the cache becomes useless.