How to Navigate a TypeScript Monorepo: Workspaces to Build Graph

A practical guide to reading a TypeScript monorepo: package manager workspaces, internal packages, tsconfig project references, the task graph and boundary rules.

By The OpenVisio Team · · 11 min read

A TypeScript monorepo has at least three overlapping graphs, and they do not always agree. The package manager has a graph of workspace packages and their declared dependencies. TypeScript has its own view of how files resolve, shaped by tsconfig.json paths and project references. And the task runner, usually Turborepo or Nx, has a graph of which build, test and lint tasks depend on which. When something behaves strangely, such as a change that does not show up, a type that resolves to stale output, or a CI step that rebuilds everything, it is almost always because two of these graphs disagree.

This guide is a method for reading a TypeScript monorepo you did not set up. It covers how to find the packages, how they reference each other, how TypeScript resolves them, how the build graph is defined, and where boundaries are enforced. The commands are for pnpm, npm and Yarn workspaces with Turborepo or Nx, which covers most repos you will meet.

Key takeaways

  • Start with the root package.json and the workspace definition (pnpm-workspace.yaml or the workspaces field). That is the list of packages.
  • Distinguish apps (deployable, nothing depends on them) from packages (libraries other workspaces import).
  • For each internal package, check its exports or main and types fields. They decide whether consumers get source files or built output.
  • Read tsconfig inheritance and paths. Path aliases can bypass the package graph entirely.
  • turbo.json or nx.json and project.json define the task graph. Ask the tool to print it instead of reading it.
  • Boundaries need enforcement: dependency-cruiser, eslint-plugin-boundaries or Nx's module boundary rule.

Step 1: Identify the package manager and the workspaces

The lockfile tells you the package manager: pnpm-lock.yaml, package-lock.json, yarn.lock (check .yarnrc.yml for Yarn Berry and Plug'n'Play), or bun.lock. Use that manager and nothing else; installing with a different one produces a different node_modules layout and confusing failures.

Then find the workspace globs. pnpm uses a separate file:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  - 'tooling/*'

npm, Yarn and Bun use the root package.json:

{
  "private": true,
  "workspaces": ["apps/*", "packages/*"]
}

List what those globs resolve to:

pnpm -r ls --depth -1            # pnpm: every workspace package
npm query .workspace | jq -r '.[].name'   # npm 8.16+
yarn workspaces list             # Yarn Berry

Sort the results into apps and packages. Apps (apps/web, apps/api, apps/docs) are deployable and are usually not depended on by anything else. Packages (packages/ui, packages/db, packages/config) are libraries. A third category, tooling packages like @acme/eslint-config or @acme/tsconfig, hold shared configuration.

Step 2: Read how packages depend on each other

Each workspace's package.json declares its internal dependencies. pnpm, Yarn and Bun use the workspace: protocol, which guarantees the local package is used:

{
  "name": "@acme/web",
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/db": "workspace:*",
    "next": "15.0.3"
  }
}

npm workspaces do not support workspace: and use a normal version range instead, linking the local package if it matches. To find who depends on a package:

pnpm why @acme/ui -r
pnpm --filter "...@acme/ui" ls --depth -1   # @acme/ui and everything that depends on it
npm ls @acme/ui

pnpm's filter syntax is worth learning because it is also how you run commands across part of the graph. --filter @acme/web... selects @acme/web and its dependencies; --filter ...@acme/ui selects @acme/ui and its dependents; --filter "[origin/main]" selects packages changed since main.

Step 3: Understand what each package exports

For every internal package, open its package.json and read the entry fields. This is the single most important detail for understanding how changes propagate:

{
  "name": "@acme/ui",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./button": "./src/button.tsx"
  }
}

There are two broad patterns:

PatternEntry points toConsumers needTrade-off
Built packagedist/ outputThe package built firstClear contract, slower dev loop, stale-build bugs
Internal (source) packagesrc/*.ts filesThe consumer's bundler to compile itFast, no build step, every consumer compiles it

Source packages are common in app-heavy monorepos, often paired with transpilePackages in a Next.js config. Built packages are common when packages are published to npm. Many repos mix the two. When a change in a package does not appear in an app, check whether the app is reading stale dist/ output.

The exports field also restricts what consumers can import. If a package only exports ".", a deep import like @acme/ui/src/internal/thing fails under modern resolution, which is a useful boundary in its own right.

Step 4: Read the tsconfig setup

TypeScript has its own resolution rules, configured through tsconfig.json. Most monorepos have a shared base config and per-package configs that extend it:

// packages/ui/tsconfig.json
{
  "extends": "@acme/tsconfig/react-library.json",
  "compilerOptions": { "outDir": "dist", "rootDir": "src" },
  "include": ["src"]
}

To see the fully resolved configuration for a package, including everything inherited:

npx tsc -p packages/ui --showConfig

Check three settings in particular:

moduleResolution. bundler, node16 or nodenext respect package exports. The older node (also called node10) ignores exports and uses main and types, so a package can resolve differently in type-checking than at runtime.

paths. Aliases like "@acme/ui": ["../../packages/ui/src"] point TypeScript directly at another package's source. This bypasses the package manager graph: an app can import a package it never declared as a dependency, and the type checker will not complain. Paths in a shared base config are a common source of undeclared dependencies.

Project references. A root tsconfig.json with references, and packages with "composite": true, let TypeScript build the repo incrementally in dependency order:

// tsconfig.json (root)
{
  "files": [],
  "references": [
    { "path": "packages/ui" },
    { "path": "packages/db" },
    { "path": "apps/web" }
  ]
}
npx tsc -b            # build all referenced projects in order
npx tsc -b --verbose  # show what is up to date and what is rebuilt

If the repo uses references, the reference graph should match the package dependency graph. When it does not, type-checking can use stale declarations from a package that has not been rebuilt.

Step 5: Read the task graph

The task runner decides what runs, in what order, and what can be cached. In Turborepo, turbo.json defines tasks (called pipeline in Turborepo 1.x and tasks in 2.x):

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "test": { "dependsOn": ["^build"] },
    "lint": {},
    "dev": { "cache": false, "persistent": true }
  }
}

The ^ prefix means "this task in my dependencies first". So build builds every dependency before the package itself. outputs defines what gets cached, and inputs (defaulting to all files in the package) defines what invalidates the cache. Ask Turborepo to show the plan instead of reasoning about it:

turbo run build --filter=@acme/web... --dry=json | jq '.tasks[].taskId'
turbo run build --graph=graph.html

Nx stores configuration in nx.json (targetDefaults) and in each project's project.json or package.json, and can infer tasks from tool configs via plugins. Its graph viewer is the quickest way in:

npx nx graph                       # interactive project graph in the browser
npx nx show project web --web      # resolved targets for one project
npx nx affected -t test            # run tests only for projects affected by your changes

Whichever tool is used, also read the root package.json scripts and the CI workflow. CI usually reveals the real usage, such as which filters run on pull requests and whether remote caching is enabled.

Step 6: Find and check boundaries

Without enforcement, any package can import any other, and over time the graph turns into a web. Look for one of these:

  • Nx module boundaries. The @nx/enforce-module-boundaries ESLint rule with depConstraints based on project tags, such as type:app may depend on type:feature and type:ui, but type:ui may only depend on type:util.
  • eslint-plugin-boundaries. Element types defined by path patterns with allow and disallow rules between them.
  • dependency-cruiser. A .dependency-cruiser.js with forbidden rules; it can also detect cycles and orphans:
npx depcruise apps packages --config .dependency-cruiser.js
npx depcruise packages/ui/src --include-only "^packages" --output-type dot | dot -T svg > ui.svg
  • Package exports. As noted in Step 3, a narrow exports map prevents deep imports.
  • Undeclared dependency checks. Tools like knip or the import/no-extraneous-dependencies ESLint rule flag imports of packages that are not in the importing package's package.json.

If none exist, run dependency-cruiser once in a read-only mode to see the current graph and any cycles. Finding and fixing circular dependencies covers what to do with what you find.

Step 7: Trace one change through all three graphs

Pick a function in a shared package, say formatCurrency in @acme/utils, and answer:

  1. Which workspaces declare @acme/utils as a dependency? (pnpm why)
  2. Which files import it, including through path aliases? (search, or a code graph)
  3. Does the consuming app read it from source or from dist/? (exports)
  4. Which tasks rerun if you change it? (turbo run build test --filter=...[HEAD] --dry)

If the answers to 1 and 2 differ, you have an undeclared dependency. If 3 is dist/, you need to rebuild before you see changes. If 4 is "everything", the cache inputs are probably too broad. Impact analysis before refactoring expands on this kind of check.

A first-day checklist

  1. Identify the package manager from the lockfile.
  2. List workspaces and sort them into apps, packages and tooling.
  3. Read each internal package's exports, main and types.
  4. Run tsc --showConfig for one app and one package.
  5. Check moduleResolution, paths and project references.
  6. Print the task graph with Turborepo or Nx.
  7. Read the CI workflow to see how filters and caching are used.
  8. Find the boundary enforcement, or run dependency-cruiser to see the current state.
  9. Trace one shared function through all three graphs.

Codebase Knowledge by OpenVisio can index a TypeScript monorepo in your browser and show the import graph between packages in Explore, which is a useful fourth view: the graph the code actually has, as opposed to the ones the config files declare.

TypeScriptmonorepopnpmTurborepoNx

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading