Type‑Safe Dependency Graph Resolution in pnpm Workspaces with TypeScript
Introduction
Large JavaScript/TypeScript codebases often live in a monorepo. pnpm workspaces give you fast, disk‑efficient installs and a single lockfile, but they don’t enforce any rules about how packages may depend on each other. A stray import from a UI package into a low‑level utility can break architectural boundaries and introduce hidden runtime errors.
In this article we’ll build a type‑safe dependency‑graph resolver that:
- Describes allowed relationships between workspace packages with pure TypeScript types.
- Validates the graph at build time, catching illegal imports before they run.
- Generates a runtime adjacency list that can be used for tooling (e.g., custom lint rules, build ordering, or visualisation).
You’ll walk away with a reusable library (@myorg/graph-resolver) that can be dropped into any pnpm workspace, plus a concrete example from a real‑world e‑commerce monorepo.
Why type safety?
The compiler can guarantee that a package only imports from its declared “layer”. When a developer accidentally adds a forbidden import, the build fails instantly—no need for a separate lint rule or manual code‑review checklist.
1. Setting the Stage: pnpm Workspaces Basics
A typical pnpm workspace looks like this:
my-monorepo/
├─ packages/
│ ├─ core/
│ │ └─ package.json
│ ├─ ui/
│ │ └─ package.json
│ ├─ api/
│ │ └─ package.json
│ └─ utils/
│ └─ package.json
└─ pnpm-workspace.yaml
pnpm-workspace.yaml declares the packages:
packages:
- "packages/*"
Each package has its own package.json with a name field (e.g., "@myorg/core"). pnpm automatically creates symlinks in node_modules so that import { foo } from '@myorg/utils' works across the repo.
The default behaviour is permissive: any package can import any other. To enforce a layered architecture we need a graph that describes allowed edges.
2. Modeling the Dependency Graph with TypeScript
2.1 Defining Package Types
Create a central packages/graph/types.ts file that enumerates every workspace package as a string literal type:
// packages/graph/types.ts
export type PackageName =
| '@myorg/core'
| '@myorg/ui'
| '@myorg/api'
| '@myorg/utils';
2.2 Declaring Allowed Edges
We encode the allowed dependencies as a mapped type where each key is a package and the value is a tuple of packages it may depend on:
// packages/graph/allowed.ts
import type { PackageName } from './types';
export type AllowedEdges = {
'@myorg/core': ['@myorg/utils'];
'@myorg/ui': ['@myorg/core', '@myorg/utils'];
'@myorg/api': ['@myorg/core', '@myorg/utils'];
'@myorg/utils': []; // leaf node
};
The compiler now knows, for example, that @myorg/ui may import from @myorg/core and @myorg/utils but not from @myorg/api.
2.3 Utility Types for Validation
We can write a generic type that checks whether a given edge is permitted:
// packages/graph/validation.ts
import type { AllowedEdges, PackageName } from './allowed';
export type IsAllowed<
From extends PackageName,
To extends PackageName
> = To extends AllowedEdges[From][number] ? true : false;
Usage in code:
type Test1 = IsAllowed<'@myorg/ui', '@myorg/core'>; // true
type Test2 = IsAllowed<'@myorg/ui', '@myorg/api'>; // false
If a developer tries to write a function that only accepts allowed imports, the type system will reject illegal combos.
3. Building the Runtime Resolver
Static types are great, but we also need a runtime representation for tooling (e.g., topological sorting for build pipelines). The resolver reads the workspace’s package.json files, builds an adjacency list, and validates it against the compile‑time model.
3.1 Scanning Packages
// packages/graph/resolver.ts
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import type { PackageName } from './types';
import type { AllowedEdges } from './allowed';
type AdjList = Record<PackageName, PackageName[]>;
export function buildAdjacencyList(root: string): AdjList {
const packagesDir = join(root, 'packages');
const pkgFolders = readdirSync(packagesDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
const adj: AdjList = {} as AdjList;
for (const folder of pkgFolders) {
const pkgJsonPath = join(packagesDir, folder, 'package.json');
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
const name = pkg.name as PackageName;
const deps = Object.keys(pkg.dependencies || {})
.filter(dep => dep.startsWith('@myorg/')) as PackageName[];
adj[name] = deps;
}
return adj;
}
3.2 Validating the Graph
// packages/graph/validate.ts
import type { AdjList } from './resolver';
import type { AllowedEdges } from './allowed';
export function validateAdjacency(adj: AdjList): string[] {
const errors: string[] = [];
for (const [from, tos] of Object.entries(adj) as [keyof typeof adj, string[]][]) {
const allowed = (AllowedEdges as any)[from] as string[];
for (const to of tos) {
if (!allowed.includes(to)) {
errors.push(`❌ ${from} may not depend on ${to}`);
}
}
}
return errors;
}
Running the validator in a pre‑build script guarantees that any illegal edge aborts the CI pipeline:
// package.json (root)
{
"scripts": {
"graph:check": "ts-node packages/graph/cli.ts",
"build": "npm run graph:check && turbo run build"
}
}
3.3 CLI Helper
// packages/graph/cli.ts
import { buildAdjacencyList } from './resolver';
import { validateAdjacency } from './validate';
const root = process.cwd();
const adj = buildAdjacencyList(root);
const errors = validateAdjacency(adj);
if (errors.length) {
console.error('Dependency graph validation failed:');
errors.forEach(e => console.error(e));
process.exit(1);
} else {
console.log('✅ Dependency graph is clean.');
}
Now every npm run build will first ensure the graph respects the declared architecture.
4. Detecting Cycles – A Common Pitfall
Even with allowed edges, a cycle can appear (e.g., core → utils → core). Cycles break incremental builds and can cause runtime import loops.
4.1 Simple DFS Cycle Detector
// packages/graph/cycle.ts
import type { AdjList } from './resolver';
export function findCycles(adj: AdjList): string[][] {
const visited = new Set<string>();
const stack: string[] = [];
const cycles: string[][] = [];
function dfs(node: string) {
if (stack.includes(node)) {
const idx = stack.indexOf(node);
cycles.push([...stack.slice(idx), node]);
return;
}
if (visited.has(node)) return;
visited.add(node);
stack.push(node);
for (const neighbor of adj[node as keyof AdjList] ?? []) {
dfs(neighbor);
}
stack.pop();
}
for (const node of Object.keys(adj)) {
dfs(node);
}
return cycles;
}
Integrate it into the CLI:
import { findCycles } from './cycle';
const cycles = findCycles(adj);
if (cycles.length) {
console.error('⚠️ Cycles detected:');
cycles.forEach(c => console.error(' - ' + c.join(' → ')));
process.exit(1);
}
Now the build fails on both illegal edges and cycles.
5. Real‑World Example: An E‑Commerce Monorepo
Consider a shop with the following layers:
| Package | Responsibility |
|---|---|
@shop/utils |
Pure helpers (date, currency) |
@shop/core |
Domain models, business rules |
@shop/api |
Express/Koa routes, request validation |
@shop/ui |
React component library |
5.1 Declaring the Graph
// packages/graph/allowed.ts (shop specific)
export type AllowedEdges = {
'@shop/utils': [];
'@shop/core': ['@shop/utils'];
'@shop/api': ['@shop/core', '@shop/utils'];
'@shop/ui': ['@shop/core', '@shop/utils'];
};
5.2 Enforcing at Development Time
In a UI component:
// packages/ui/src/ProductCard.tsx
import { formatCurrency } from '@shop/utils'; // ✅ allowed
import { Order } from '@shop/api'; // ❌ TypeScript error
// ^ TypeScript: Type 'Order' is not assignable to type 'never'.
The IsAllowed type can be used to create a helper function that only accepts allowed imports, providing an extra safety net for dynamic require calls.
// packages/graph/guard.ts
import type { IsAllowed } from './validation';
import type { PackageName } from './types';
export function assertAllowed<From extends PackageName, To extends PackageName>(
_: IsAllowed<From, To>
) {
// no runtime code – the call site is type‑checked only
}
Usage:
assertAllowed<'@shop/ui', '@shop/api'>(false as any); // compile‑time error
6. Integrating with CI/CD
Add the validation step to GitHub Actions:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 9
- run: pnpm install --frozen-lockfile
- run: pnpm run graph:check # fails fast on illegal deps
- run: pnpm run build
Because the script runs before any compilation, developers get immediate feedback in PR checks.
7. Extending the Resolver
7.1 Optional “Peer” Layers
If you need a soft rule (e.g., UI may optionally import from API for storybook), extend AllowedEdges with a second map OptionalEdges and adjust validateAdjacency to emit warnings instead of errors.
7.2 Generating Visualisations
The adjacency list can be fed to GraphViz:
// packages/graph/viz.ts
export function toDot(adj: AdjList): string {
const lines = ['digraph G {'];
for (const [from, tos] of Object.entries(adj)) {
for (const to of tos) {
lines.push(` "${from}" -> "${to}";`);
}
}
lines.push('}');
return lines.join('\n');
}
Running node -e "console.log(require('./viz').toDot(require('./resolver').buildAdjacencyList('.')))" produces a .dot file you can render to PNG for architecture reviews.
8. Lessons Learned
| ✅ Good Practice | ❌ Pitfall |
|---|---|
Keep the type list (PackageName) as the single source of truth. |
Manually editing strings in multiple places leads to drift. |
| Run the graph check as the first step of any CI pipeline. | Relying on a lint rule only catches a subset of cases. |
| Treat the resolver as source of truth for other tooling (visualisation, build ordering). | Duplicating logic elsewhere creates maintenance overhead. |
Use readonly tuple types (['@myorg/utils']) to guarantee immutability. |
Mutable arrays can be accidentally mutated at runtime, breaking validation. |
9. Conclusion
By marrying pnpm’s workspace symlinks with TypeScript’s powerful type system, we can enforce a clean, layered dependency graph at compile time and at runtime. The approach scales from a handful of packages to dozens, and the generated adjacency list becomes a reusable artifact for build orchestration, documentation, and architecture governance.
Give it a try in your next monorepo—add the tiny @myorg/graph-resolver package, declare your allowed edges, and let the compiler guard your architecture before a single line of JavaScript runs in production.
Member discussion