In this guide
On this page ⌄
- Key takeaways
- Step 1: Identify the package manager and the workspaces
- Step 2: Read how packages depend on each other
- Step 3: Understand what each package exports
- Step 4: Read the tsconfig setup
- Step 5: Read the task graph
- Step 6: Find and check boundaries
- Step 7: Trace one change through all three graphs
- A first-day checklist
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.jsonand the workspace definition (pnpm-workspace.yamlor theworkspacesfield). 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
exportsormainandtypesfields. They decide whether consumers get source files or built output. - Read
tsconfiginheritance andpaths. Path aliases can bypass the package graph entirely. turbo.jsonornx.jsonandproject.jsondefine 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:
| Pattern | Entry points to | Consumers need | Trade-off |
|---|---|---|---|
| Built package | dist/ output | The package built first | Clear contract, slower dev loop, stale-build bugs |
| Internal (source) package | src/*.ts files | The consumer's bundler to compile it | Fast, 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-boundariesESLint rule withdepConstraintsbased on project tags, such astype:appmay depend ontype:featureandtype:ui, buttype:uimay only depend ontype:util. - eslint-plugin-boundaries. Element types defined by path patterns with allow and disallow rules between them.
- dependency-cruiser. A
.dependency-cruiser.jswith 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 narrowexportsmap prevents deep imports. - Undeclared dependency checks. Tools like
knipor theimport/no-extraneous-dependenciesESLint rule flag imports of packages that are not in the importing package'spackage.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:
- Which workspaces declare
@acme/utilsas a dependency? (pnpm why) - Which files import it, including through path aliases? (search, or a code graph)
- Does the consuming app read it from source or from
dist/? (exports) - 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
- Identify the package manager from the lockfile.
- List workspaces and sort them into apps, packages and tooling.
- Read each internal package's
exports,mainandtypes. - Run
tsc --showConfigfor one app and one package. - Check
moduleResolution,pathsand project references. - Print the task graph with Turborepo or Nx.
- Read the CI workflow to see how filters and caching are used.
- Find the boundary enforcement, or run dependency-cruiser to see the current state.
- 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.

