35.3 npm, yarn, and pnpm Workspaces with TypeScript
Right, let’s talk about workspaces. This is where the monorepo concept stops being a cute idea and starts being a real, tangible thing that saves you from an aneurysm. Without workspaces, a monorepo is just a directory full of separate projects that hate each other. Workspaces are the therapy that gets them to cooperate. They do two magical things: they hoist dependencies to avoid 47 copies of lodash, and they create symlinks so your packages can reference each other before you publish them to npm.
Think of it like this: you have a packages directory with @mycompany/ui and @mycompany/utils. Without workspaces, to use utils in ui, you’d have to npm pack and npm install the resulting tarball every time you changed a comma. It’s a nightmare. Workspaces let you import { something } from '@mycompany/utils' and it just works, as if it were already published. It’s development nirvana.
The Big Three: npm, Yarn, and pnpm
All three major package managers implement workspaces, but they are not created equal. Your choice here is probably the single biggest technical decision for your monorepo’s day-to-day feel.
npm has workspaces, but let’s be honest, it’s the newcomer playing catch-up. Its support is functional but can feel a bit bolted-on. Its algorithm for resolving the dependency tree (the “arborist”) can sometimes be slower than its competitors, especially at scale. It’s a solid choice if you’re already deep in the npm ecosystem and want to keep things simple.
Yarn (especially Yarn 2+ with “Berry”) is the OG that really pushed the workspace concept into the mainstream. Its Plug’n’Play (PnP) mode is a wild, brilliant, and sometimes frustratingly strict approach that eliminates node_modules altogether. It’s faster and more deterministic, but it can be a rough ride with certain legacy or badly-behaved packages. Its regular node-modules linker is also excellent and less opinionated.
pnpm is the speed demon. Its whole model is based on hard links and symlinks into a single content-addressable store on your disk. This means it’s incredibly fast and incredibly disk-space efficient. If you’ve ever wept at the size of your node_modules, pnpm is your salvation. Its workspace implementation is first-class and, in my opinion, often the best-balanced choice for new projects. It’s strict like Yarn but without the sometimes-jarring paradigm shift of PnP.
Here’s what a basic package.json looks like at your monorepo root for each:
npm / Yarn (classic)
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*"
]
}
Yarn Berry
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*"
],
"packageManager": "yarn@3.6.0" // Berry is big on being explicit
}
pnpm
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*"
]
}
And you must have a pnpm-workspace.yaml file:
packages:
- 'packages/*'
Yes, pnpm requires both. No, it doesn’t make a ton of sense. It’s a quirk. We move on.
Taming TypeScript in the Workspace Jungle
Here’s the part the package managers don’t solve for you: TypeScript has no innate concept of workspaces. You just told TypeScript, “Hey, trust me, @mycompany/utils is right over there,” and TypeScript, being a stubbornly literal compiler, replies, “I have no idea what you’re talking about. Cannot find module.”
You have to bridge this gap yourself. You have two primary weapons: project references and path mapping. You will likely use both.
Project references are the “correct”, long-term solution. You tell TypeScript about the relationship between your projects so it can build them in the correct order and understand when one depends on another.
Path mapping is the quick-and-dirty, incredibly useful hack. You tell the TypeScript compiler, “Any time you see @mycompany/*, go look for it in this specific directory.” It’s simpler to set up but doesn’t help with incremental builds.
The professional move is to use them together. Use path mapping for lightning-fast editor feedback and IntelliSense, and use project references for actually building everything correctly.
First, in your root tsconfig.json, you set up the path mapping and common compiler options. This file acts as your defaults.
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"composite": true, // Crucial for project references!
"declaration": true,
"declarationMap": true,
"baseUrl": ".",
"paths": {
"@mycompany/*": ["packages/*/src"]
}
}
}
Then, in each package’s tsconfig.json, you extend from the root config and specify its own details. Here’s packages/utils/tsconfig.json:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [] // This utils package has no internal dependencies
}
And here’s packages/ui/tsconfig.json, which depends on utils:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../utils" } // This is the project reference
]
}
Now, to build the entire monorepo, you use tsc --build (or tsc -b) from the root. It will figure out the dependency graph and build things in the right order. It’s beautiful.
The Crucial .npmignore (or just use files)
Here’s a classic “I just wasted three hours” pitfall. You’ve set up your utils package beautifully. You run npm publish from packages/utils. It publishes… and your dist directory isn’t included. Why? Because you probably have a .gitignore that ignores dist, and npm helpfully uses your .gitignore as a base for .npmignore if you don’t provide one.
The solution is to be explicit. In each package, either use a very specific .npmignore or, my strong preference, use the files array in package.json to whitelist exactly what should be published. It’s safer.
// In packages/utils/package.json
{
"name": "@mycompany/utils",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts", // Critical for TS consumers!
"files": [
"dist/**/*" // This is all that gets published. Nothing else.
]
}
This way, your src directory, your tsconfig.json, and your leftover lunch don’t accidentally get published to npm. Trust me, your future self will thank you for this discipline.