Detecting Undeclared Cross-Package Imports
In a monorepo it is easy for one package to import another without declaring it: a relative path that climbs out of the package (../../ui/src/Button), a bare import that resolves only because of hoisting, or a TypeScript path alias that points into a sibling's source. The code works locally, so nobody notices — until the task runner builds packages in the wrong order, the cache serves stale output, or a published package fails for every consumer. This guide shows how undeclared imports break monorepo tooling, how to find them with lint rules and graph tools, and how to stop new ones from landing.
What an undeclared import looks like
Three patterns account for nearly all of them:
// 1. Relative import that escapes the package boundary
// packages/forms/src/Field.tsx
import { Button } from '../../ui/src/Button';
// 2. Bare import of a sibling that is not in package.json
// packages/forms/src/Field.tsx — @acme/ui is not a dependency of @acme/forms
import { Button } from '@acme/ui';
// 3. A tsconfig path alias into another package's source
// tsconfig.json: "paths": { "@ui/*": ["../ui/src/*"] }
import { Button } from '@ui/Button';
The symptoms appear far from the import itself:
# Task runner builds forms before ui, because it does not know they are related
packages/forms build: src/Field.tsx(1,24): error TS2307: Cannot find module '@acme/ui' or its corresponding type declarations.
# Cache hit returns stale output after a change to ui
• Packages in scope: @acme/forms
• Running build in 1 packages
@acme/forms:build: cache hit, replaying logs 9f3c1a2e7b...
# Consumer installs the published package
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@acme/ui' imported from /app/node_modules/@acme/forms/dist/Field.js
Why undeclared imports break monorepo tooling
Every monorepo tool builds its package graph from manifests, not from source code. Turborepo, Nx (in package-based mode), pnpm's --filter, Changesets and Renovate all read dependencies and devDependencies to decide which packages depend on which. An import that is not declared is an edge missing from that graph. The general model is described in Cross-Package Dependency Management.
The consequences follow directly:
- Wrong build order. Topological ordering does not know
formsneedsuibuilt first. - Wrong cache keys. A change to
uidoes not invalidateforms's cache, so stale output is replayed. See Debugging Why a Turborepo Task Is Never Cached for the opposite problem. - Wrong affected sets.
--filter "...[origin/main]"andnx affectedskipformswhen onlyuichanged, so its tests never run. - Broken publishing. The published
formsdoes not listuias a dependency, so consumers never install it. - Hidden cycles. An undeclared edge can close a dependency cycle that tools would otherwise report, as covered in Debugging Circular Dependencies in Monorepos.
Detection with lint rules
ESLint catches all three patterns at edit time. Two rules from eslint-plugin-import (or its maintained fork eslint-plugin-import-x) do most of the work:
// eslint.config.js (shared config package)
import importX from 'eslint-plugin-import-x';
export default [
{
plugins: { 'import-x': importX },
rules: {
// Bare imports must be declared in the nearest package.json
'import-x/no-extraneous-dependencies': ['error', {
devDependencies: ['**/*.test.{ts,tsx}', '**/*.stories.tsx', '**/vite.config.ts'],
includeInternal: true,
}],
// Relative imports must not climb out of the package
'import-x/no-relative-packages': 'error',
},
},
];
no-relative-packages reports any relative import that resolves into a different package (a folder with its own package.json) and suggests the package name instead. no-extraneous-dependencies then requires that package name to be declared. Remove cross-package paths aliases from tsconfig.json entirely; the workspace protocol makes them unnecessary.
Detection with graph tools
Lint catches imports file by file. Graph tools compare the whole import graph with the declared graph, which is useful for an initial audit and as a CI backstop.
Knip reports undeclared imports per workspace as "unlisted dependencies", including sibling packages:
npx knip --include unlisted
# Unlisted dependencies (1)
# @acme/ui packages/forms/src/Field.tsx
dependency-cruiser can express boundary rules and fail on violations:
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'no-cross-package-relative',
severity: 'error',
from: { path: '^packages/([^/]+)/' },
to: { path: '^packages/([^/]+)/', pathNot: '^packages/$1/' },
},
],
options: { tsConfig: { fileName: 'tsconfig.json' } },
};
Nx projects get this built in: the @nx/enforce-module-boundaries lint rule reports imports of projects that are not declared as dependencies and relative imports across project boundaries, as described in Enforcing Module Boundaries with Nx Tags.
Fixing what you find
For each finding, decide which way the dependency should go:
- Legitimate dependency — declare it:
pnpm add @acme/ui --workspace --filter @acme/forms, then replace relative imports with the package name. - Shared code in the wrong place — if two packages both reach into a third package's internals, the shared code probably deserves its own package with a public entry point. Extract it and depend on it properly.
- Accidental import — an editor auto-import picked the wrong path. Remove it and import from the intended package.
After fixing, the import must resolve through the dependency's exports map. If @acme/ui does not export Button from a public entry, add it — do not deep-import its source.
Why TypeScript path aliases cause the most trouble
Of the three patterns, cross-package paths aliases are the hardest to remove because they are usually deliberate. Teams add "@acme/ui": ["../ui/src/index.ts"] to get instant type-checking against a sibling's source without building it. It works in the editor, and it silently bypasses everything the package manager and task runner know about the relationship.
The replacement gives you the same developer experience with a declared edge. Declare the dependency with workspace:*, and point the dependency's exports at its source for development — either with a custom condition that your bundler and TypeScript resolve ("development": "./src/index.ts" in exports, plus customConditions: ["development"] in tsconfig.json), or by using TypeScript project references so the editor navigates to source while builds use declarations. Either way the import is @acme/ui, resolved through the package's own manifest, and every tool sees the edge. The detailed setup is in Using Internal Packages Without a Build Step.
Rolling the rules out without a big bang
Turning on strict import rules in a large repository can produce hundreds of errors at once. Three techniques keep the rollout incremental. First, run the graph tool once to produce a baseline list of violations and fix the packages with the fewest violations first, where each fix is a small pull request. Second, enable the lint rules as error for new files only — ESLint's overrides with a file list, or a ratchet script that fails only when the violation count increases — so the problem cannot grow while you work through the backlog. Third, fix leaf packages (those with no dependents) before core packages, because declaring a dependency in a core package can change build order for many others and is best done when the rest of the graph is already accurate.
Worked example: an affected-only CI that skipped a broken package
A team's CI runs tests only for affected packages. A pull request renames a prop in @acme/ui's Button; CI runs ui and its declared dependents, all green. After merge, @acme/forms fails in the nightly full build: it had imported Button through ../../ui/src/Button, so the affected calculation never selected it. The team adds no-relative-packages and no-extraneous-dependencies to the shared ESLint config (which reports eleven more violations across four packages), declares the real dependencies, and adds knip --include unlisted to CI. From then on, affected runs include every package that really uses the changed code.
Prevention and CI/CD guardrails
- Enable
no-relative-packagesandno-extraneous-dependenciesin the shared ESLint config for every package. - Remove cross-package
pathsaliases from TypeScript configuration. - Run Knip's unlisted check in CI as a backstop for files lint does not cover.
- Publish-time smoke tests catch the remaining cases where a published package imports something it does not declare.
Frequently Asked Questions
Why does the import work locally if it is undeclared? Hoisting or relative paths make the file reachable on disk. Resolution succeeds, but no tool that reads manifests knows about the relationship.
Is a devDependency enough for an internal package used only in tests?
Yes. Declare it in devDependencies with workspace:*; task runners still see the edge for test tasks.
Should type-only imports be declared too?
Yes. import type still requires the package's declarations to be built and resolvable, so the edge matters for build order and caching even though no runtime code is loaded.
Do these rules slow down ESLint noticeably?
no-extraneous-dependencies reads the nearest package.json for each file and caches it, so the cost is small. no-relative-packages resolves each relative import, which adds a little more; on very large repositories, run the full rule set in CI and a lighter set in the editor if needed.
Related
- Cross-Package Dependency Management describes how packages should depend on each other.
- Fixing Phantom Dependencies After Switching to pnpm covers undeclared imports of external packages.
- Finding Unused Dependencies and Exports with Knip sets up the graph checks used here.
- Fixing Slow Monorepo CI with Affected Builds depends on an accurate declared graph.