Type‑Safe Dependency Management in Monorepos: npm Workspaces vs. Yarn Berry
Introduction
Monorepos are great for sharing code, enforcing standards, and simplifying releases. Yet they introduce a subtle danger: type drift. When several packages depend on each other, a single mismatched version or a missing types field can break the whole TypeScript build, often only after a CI run.
This article shows how to combine the built‑in capabilities of npm workspaces and the advanced features of Yarn Berry (v2+) to create a type‑safe dependency graph. You’ll walk away with a checklist, concrete package.json and tsconfig.json snippets, and a small real‑world example that you can drop into any JavaScript/TypeScript monorepo.
Goal: By the end of this guide you should be able to guarantee that every internal package:Exposes a correct.d.tsfile (ortypesentry).Is consumed only with a compatible version range.Is lint‑checked for accidental runtime‑only imports.
1. Why “type‑safe” matters in a monorepo
| Problem | Symptom | Typical cause |
|---|---|---|
| Missing type declarations | TS7016: Could not find a declaration file for module 'foo' |
Library forgets "types" field or ships only JS. |
| Duplicate type versions | Conflicting interfaces, subtle bugs at runtime | Two packages depend on different major versions of the same library. |
| Unintended runtime imports | Code runs in the browser but pulls in a Node‑only module | package.json exports not constrained, or devDependencies used in production code. |
When you have dozens of inter‑dependent packages, manually auditing each package.json is error‑prone. Automation is the answer.
2. npm Workspaces – the baseline
npm added workspaces in v7, giving you a lightweight monorepo without extra tooling.
2.1 Setting up a workspace
# repo root
npm init -y
// package.json (root)
{
"private": true,
"workspaces": [
"packages/*"
]
}
Folder layout:
/repo
/packages
/ui
/utils
/api
Running npm install at the root creates a single node_modules folder that hoists shared dependencies, while each workspace gets its own package.json.
2.2 Enforcing type‑safe imports with exports and types
Add an exports map to each package that explicitly lists the entry points that are safe to import:
// packages/utils/package.json
{
"name": "@myorg/utils",
"version": "1.2.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./internal": {
"import": "./src/internal.ts",
"types": "./src/internal.d.ts"
}
}
}
Why?
- Consumers can only import the public
"."entry unless they deliberately opt‑in to the internal path. - TypeScript reads the
"types"field, guaranteeing the correct declaration file is used.
2.3 Using peerDependencies for version contracts
If @myorg/api needs a specific version of @myorg/utils, declare it as a peer:
// packages/api/package.json
{
"name": "@myorg/api",
"version": "0.9.0",
"peerDependencies": {
"@myorg/utils": "^1.2.0"
}
}
npm will warn during install if the workspace provides a mismatched version, preventing accidental version skew.
3. Yarn Berry – taking type safety further
Yarn Berry (v2+) introduces a plug‑in architecture that can enforce constraints before code even compiles.
3.1 Installing Yarn Berry in the same repo
npm install -g yarn@berry # or `corepack enable && corepack prepare yarn@stable --activate`
yarn set version berry
Create a .yarnrc.yml at the repo root:
# .yarnrc.yml
nodeLinker: node-modules
Now you have both npm workspaces (for compatibility) and Yarn Berry (for constraints). Yarn will respect the same workspaces field.
3.2 The constraints plugin
Yarn’s constraints plugin lets you write a tiny script that validates the dependency graph.
3.2.1 Enable the plugin
yarn plugin import constraints
3.2.2 Write a constraint file
Create constraints.proposed.js:
// constraints.proposed.js
module.exports = async (project) => {
// 1️⃣ Ensure every workspace has a "types" field
for (const workspace of project.workspaces) {
const pkg = workspace.manifest.raw;
if (!pkg.types && !pkg.exports?.['.']?.types) {
throw new Error(
`Workspace ${workspace.name} must declare a "types" field or an export with "types".`
);
}
}
// 2️⃣ Enforce same major version for shared dev deps (e.g., typescript)
const shared = new Map();
for (const workspace of project.workspaces) {
const deps = workspace.manifest.dependencies;
for (const [name, range] of Object.entries(deps)) {
if (name.startsWith('@types/') || name === 'typescript') {
const major = range.replace(/^[~^]/, '').split('.')[0];
if (shared.has(name) && shared.get(name) !== major) {
throw new Error(
`Version mismatch for ${name}: ${shared.get(name)} vs ${major} in ${workspace.name}`
);
}
shared.set(name, major);
}
}
}
};
Run it with:
yarn constraints check
If any workspace violates the rule, the command exits with a clear error, stopping CI before compilation.
3.3 typeVersions for multi‑target libraries
When you ship a library that supports both modern ESM and legacy CommonJS, you can use typeVersions to give each consumer the right typings.
// packages/ui/package.json
{
"name": "@myorg/ui",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"typeVersions": {
"*": {
"esm": ["dist/esm/**/*.d.ts"],
"cjs": ["dist/cjs/**/*.d.ts"]
}
}
}
TypeScript resolves the appropriate .d.ts based on the import style, preventing mismatched type definitions.
4. Putting it together – a real‑world example
Imagine a small SaaS product with three internal packages:
| Package | Purpose |
|---|---|
@myorg/utils |
Shared helpers (date, uuid) |
@myorg/api |
Express server, depends on utils |
@myorg/ui |
React component library, depends on utils |
4.1 Repository skeleton
repo/
├─ .yarnrc.yml
├─ package.json # root (npm workspaces)
├─ constraints.proposed.js
└─ packages/
├─ utils/
│ ├─ src/index.ts
│ ├─ tsconfig.json
│ └─ package.json
├─ api/
│ ├─ src/server.ts
│ ├─ tsconfig.json
│ └─ package.json
└─ ui/
├─ src/Button.tsx
├─ tsconfig.json
└─ package.json
4.2 utils – the source of truth
// packages/utils/src/index.ts
export function uuid(): string {
return crypto.randomUUID();
}
export function formatDate(d: Date, locale = 'en-US'): string {
return new Intl.DateTimeFormat(locale).format(d);
}
tsconfig.json enables declaration generation:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"module": "ESNext",
"target": "ES2020",
"sourceMap": true,
"composite": true,
"incremental": true
},
"include": ["src"]
}
package.json (excerpt):
{
"name": "@myorg/utils",
"version": "1.3.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc -b"
}
}
4.3 Consuming utils in api
// packages/api/src/server.ts
import express from 'express';
import { uuid, formatDate } from '@myorg/utils';
const app = express();
app.get('/ping', (_, res) => {
res.json({ id: uuid(), now: formatDate(new Date()) });
});
export default app;
api/package.json declares a peer on utils:
{
"name": "@myorg/api",
"version": "0.5.0",
"main": "dist/server.js",
"types": "dist/server.d.ts",
"peerDependencies": {
"@myorg/utils": "^1.3.0"
},
"scripts": {
"build": "tsc -b"
}
}
If a future commit bumps utils to 2.0.0 without updating the peer range, yarn constraints check will fail, alerting the team before the API is deployed.
4.4 Using utils in a React component
// packages/ui/src/Button.tsx
import React from 'react';
import { uuid } from '@myorg/utils';
export const Button: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = (props) => (
<button data-id={uuid()} {...props} />
);
Because ui also lists @myorg/utils as a dependency, Yarn hoists the same version used by api. The constraints script guarantees that both packages reference the exact same major version, eliminating duplicate copies in the final bundle.
5. CI/CD checklist
- Run TypeScript’s
--noEmiton the whole repo to catch stray imports. - Optional: Add a Git hook (
husky+lint-staged) that runsyarn constraints checkon every commit.
Build each workspace in order (use -p for project references)
yarn workspaces foreach -pt run build
Run Yarn constraints
yarn constraints check
6. When to choose npm vs. Yarn Berry
| Feature | npm Workspaces | Yarn Berry |
|---|---|---|
| Zero‑config hoisting | ✅ (basic) | ✅ (advanced) |
| Enforced constraints (pre‑install) | ❌ (needs external script) | ✅ (built‑in plugin) |
Plug‑in ecosystem (e.g., constraints, node-modules) |
❌ | ✅ |
| Compatibility with existing npm scripts | ✅ | ✅ (runs npm scripts) |
| Learning curve | Low | Moderate (YAML config + plugins) |
If you only need simple hoisting and are comfortable with a post‑install lint step, npm workspaces may be enough. For larger teams that want guardrails baked into the package manager, Yarn Berry’s constraints plugin is a compelling addition.
7. TL;DR – Actionable takeaways
- Always declare a
typesfield or anexportsentry with"types"in every internal package. - Use
peerDependenciesto lock major versions across workspaces. - Add Yarn Berry’s
constraintsplugin to enforce the above rules automatically. - Leverage
typeVersionswhen publishing dual‑module libraries. - Integrate the check into CI so type‑safe dependency violations never reach production.
By treating the dependency graph as a first‑class citizen—just like your source code—you eliminate a whole class of runtime bugs and keep your monorepo healthy as it scales.
Member discussion