Skip to content
Fallow home
All docs pages

Dead code analysis

Find the code, exports, and dependencies you can delete from a TypeScript or JavaScript project. Fallow also reports class members, circular dependencies, and boundary violations from the same module graph.

fallow dead-code tells you which files, exports, and dependencies you can delete. It builds a module graph from your entry points and reports everything that the graph does not reach.

The graph build is deterministic, so a developer, a CI pipeline, and a coding assistant get the same result for the same code.

fallow dead-code

Issue types

Fallow reports these core issue types:

Issue typeDescription
Unused filesFiles not reachable from any entry point
Unused exportsExported symbols never imported elsewhere
Unused typesType aliases and interfaces never referenced
Unused dependenciesPackages in dependencies never imported or used as script binaries
Unused devDependenciesPackages in devDependencies never imported or used as script binaries
Unused optionalDependenciesPackages in optionalDependencies never imported or used as script binaries
Unused enum membersEnum values never referenced
Unused class membersClass methods and properties never referenced outside their class. Tracks inheritance, skips framework lifecycle methods, and lets you exclude decorators with ignoreDecorators
Unused catalog entriesEntries in the catalog: or catalogs: maps of pnpm-workspace.yaml that no workspace package references with the catalog: protocol
Unresolved importsImport specifiers that cannot be resolved
Unlisted dependenciesImported packages missing from package.json
Duplicate exportsSame symbol exported from multiple modules
Circular dependenciesModules that import each other directly or transitively
Boundary violationsImports that cross user-defined architecture zone boundaries
Type-only dependenciesProduction dependencies that only import type statements use (move them to devDependencies)
Test-only dependenciesProduction dependencies that only test files import (move them to devDependencies)
Stale suppressionsfallow-ignore comments or @expected-unused JSDoc tags that no longer match any issue

Fallow also has framework-specific rules, such as unused component props and route collisions, and opt-in rules, such as private type leaks. To set the severity of a rule, see Rules & severity. To see the filter flag of each issue type, run fallow dead-code --help.

Typical output with more than one issue type:

── Unused Code ─────────────────────────────────────

● Unused files (3)
  scripts/check-db.ts
  src/features/forecasting/hooks/useCashFlowForecast.ts
  src/features/forecasting/hooks/useIncomeForecast.ts
  Files not reachable from any entry point
  https://docs.fallow.tools/explanations/dead-code#unused-files
  To suppress: // fallow-ignore-file unused-file

● Unused exports (8)
  test/component-helpers.tsx (5)
    :1 act (re-export)
    :1 waitFor (re-export)
    :1 within (re-export)
    :33 ThemeContext
    :58 ToastContext
  src/server/jobs/queue.ts (3)
    :61 enqueueJobDelayed
    :206 sweepStuckProcessingJobs
    :276 getDeadLetterJobs
  Exported symbols with no known consumers
  https://docs.fallow.tools/explanations/dead-code#unused-exports
  To auto-fix: fallow fix --dry-run
  To suppress: // fallow-ignore-next-line unused-export
  3 in src, 5 in test files

✗ 3 files · 8 exports (0.16s)

Filtering by issue type

To report only some issue types, pass their flags:

fallow dead-code --unused-files
fallow dead-code --unused-exports --unused-types
fallow dead-code --unresolved-imports --unlisted-deps

Output formats

Colored terminal output for people to read.

fallow dead-code --format human
── Unused Code ─────────────────────────────────────

● Unused files (1)
  src/server/jobs/worker.ts
  Files not reachable from any entry point
  https://docs.fallow.tools/explanations/dead-code#unused-files

● Unused exports (3)
  src/server/jobs/queue.ts (2)
    :61 enqueueJobDelayed
    :206 sweepStuckProcessingJobs
  src/components/Card/index.ts
    :1 CardFooter
  Exported symbols with no known consumers
  https://docs.fallow.tools/explanations/dead-code#unused-exports
  To auto-fix: fallow fix --dry-run
  To suppress: // fallow-ignore-next-line unused-export

✗ 1 file · 3 exports (0.16s)

Incremental analysis

To check only the files that changed since a git ref, pass --changed-since:

fallow dead-code --changed-since main
fallow dead-code --changed-since HEAD~5

In CI, this reports only the issues that a pull request adds.

── Unused Code ─────────────────────────────────────

● Unused exports (2)
  src/features/savings/hooks/usePotGroups.ts
    :8 usePotGroupTotals
  src/server/jobs/queue.ts
    :276 getDeadLetterJobs
  Exported symbols with no known consumers
  https://docs.fallow.tools/explanations/dead-code#unused-exports

✗ 2 exports (0.04s)

Baseline comparison

To adopt fallow on an existing codebase, save the current issues as a baseline. Fallow then fails only on new issues:

# Save current issues as baseline
fallow dead-code --save-baseline fallow-baselines/dead-code.json

# Only fail on new issues (compared to baseline)
fallow dead-code --baseline fallow-baselines/dead-code.json

A dead-code baseline stores one line-free key for each finding: the rule, the path and the names. When you add lines above a finding, the baseline still hides it. Each saved entry hides one finding, so a second finding with the same key is new. A baseline from an earlier release still loads. Save it again once to get the line-free form. See Incremental.

Debugging

To see why fallow counts an export, a file, or a dependency as used or unused, trace it:

fallow dead-code --trace src/utils.ts:formatDate
fallow dead-code --trace-file src/utils.ts
fallow dead-code --trace-dependency lodash

How it works

Fallow parses your code with Oxc and resolves bindings with scope analysis. It does not run the TypeScript compiler and does not use type information, which keeps the analysis fast.

flowchart TB
  A["Discovery<br/>Entry points from package.json + plugins"] --> B["Parsing<br/>Parallel with Oxc + oxc_semantic + rayon"]
  B --> C["Resolution<br/>Import specifiers to file paths"]
  C --> D["Graph<br/>Module graph with re-export chains"]
  D --> E["Analysis<br/>Walk from entry points, report unreachable code"]

Fallow works best with isolatedModules: true, which esbuild, swc, and Vite require. oxc_semantic scope analysis finds import bindings that the file never reads. Older tsc-only projects without isolatedModules can still get edge cases with type-only imports.

Script binary analysis

A package that you only use from a package.json script is not an unused dependency. Fallow reads your scripts to find these packages. For "lint": "oxlint src/", fallow sees that the oxlint package supplies the oxlint binary and marks the package as used.

Fallow reads these parts of a script:

  • Binary names. Fallow maps commands such as tsc, vitest, or next to their packages (typescript, vitest, next). It does not report these packages as unused, even when no source file imports them.
  • --config arguments. When a script names a config file (for example jest --config jest.e2e.config.ts), fallow makes that file an entry point, so it does not report the file as unused.
  • File path arguments. Files that a script names directly (for example node scripts/seed.js) also become entry points.
  • Env wrappers and package manager runners. Fallow removes the cross-env, dotenv, env, npx, pnpx, yarn dlx, pnpm exec, npm exec --, yarn run, bun run, or varlock run -- prefix to find the real binary. The flags of these prefixes are also removed, for example dotenv -e .env.ci --, pnpm --filter web exec, and pnpm -r exec. yarn <bin>, yarn run <bin>, pnpm <bin>, and bun run <bin> count as the binary when no script has that name.
  • Script calls. A call such as npm run lint -- src/a.ts, npm run lint src/a.ts, yarn lint src/a.ts, or pnpm lint src/a.ts runs the lint script with the extra arguments. Fallow reads it as the script body plus those arguments, as the package manager does. As in npm 7 and later, npm forwards the positional arguments without -- and reads the --prefixed arguments before -- as npm config. npm config flags that take a value, such as --tag, --otp, or --node-options, also consume the next argument, so npm run lint --tag next src/a.ts forwards only src/a.ts. A declared script with the name of a tool runs instead of the tool: with "oxlint": "node tools/check.js", yarn oxlint src/a.ts keeps src/a.ts as an entry point.
  • Other workspace packages and directories. A command in a workspace package that it selects resolves its file arguments against the directory of that package. With the web package in packages/web, yarn workspace web node scripts/a.ts, pnpm --filter web exec tsx scripts/a.ts, npm exec -w web -- tsx scripts/a.ts, and a call of a web script such as npm run -w web gen -- scripts/a.ts make packages/web/scripts/a.ts an entry point. A pnpm filter can be a name, a name glob ('@acme/*'), a directory (./packages/*, {packages/web}), or an exclusion ('!web'). A selection of several packages resolves the file in each package where the file exists. This includes a command in every package: pnpm -r exec tsx scripts/a.ts, yarn workspaces foreach -A exec tsx scripts/a.ts (narrowed by --include and --exclude), yarn workspaces run gen scripts/a.ts, and npm --workspaces run gen -- scripts/a.ts. yarn workspaces foreach -A also runs in the root package, as yarn does, and its --include and --exclude match a workspace name or directory (. is the root). pnpm -w, a pnpm filter with the name or directory of the root package, and yarn workspace with the name of the root package also select the root package. An npm workspace name or directory does not select it, as in npm. --include-workspace-root adds the root package: in pnpm to -r and to a filter that only excludes packages (--filter '!web'), and in npm to every workspace selection (-w web, --workspaces), also with the short form -iwr. Without it, pnpm -r, yarn workspaces run, and npm --workspaces leave out the root package. From a workspace package, npm --workspaces selects only that package. A script call in the directory of a workspace package, such as pnpm -C packages/web run gen scripts/a.ts, npm --prefix packages/web run gen -- scripts/a.ts, or yarn --cwd packages/web gen scripts/a.ts, runs the script of that package with the arguments. The scripts of the root package and of each workspace package resolve these forms in the same way. A start script that calls a script in the selected packages, such as pnpm -r run serve or pnpm -C packages/web run serve, makes the files of that script runtime entry points in each package, as for the files of start itself. A linter target in a selected package, such as yarn workspace web oxlint src/a.ts or pnpm -r run lint -- src/a.ts, still makes no entry point. A pnpm dependency or changed-package filter (web..., [origin/main]), another yarn workspaces foreach selection (--since, --recursive, --from, --worktree, --no-private), and the task runners turbo, nx, and lerna (turbo run lint -- src/a.ts) make no entry points. The binary still counts as a used dependency. A command in another directory (pnpm -C docs exec tsx scripts/a.ts, npm --prefix, yarn --cwd) resolves its file arguments against that directory. yarn node <file> runs the file with Node.js, also after yarn --cwd <dir> or yarn workspace <name>.
{
  "scripts": {
    "build": "tsc && vite build",
    "test": "vitest --config vitest.config.ts",
    "lint": "cross-env NODE_ENV=production oxlint src/"
  }
}

In this example, fallow detects typescript, vite, vitest, and oxlint as used dependencies, and vitest.config.ts as an entry point.

Formatter and linter targets

A formatter or a linter reads its file arguments, but it does not run them. So the file arguments of these tools do not become entry points: oxlint src/, oxfmt --check ., eslint src/a.ts, and prettier --check "**/*.ts" keep every file that no import reaches reportable as unused. This applies to all the command forms in the list above: a direct call, a package manager runner, an env wrapper, and a script call. For example, with the script "lint": "oxlint", the call npm run lint -- src/a.ts does not make src/a.ts an entry point.

Fallow knows these tools: alex, Biome, CSpell, dependency-cruiser, dprint, EditorConfig checkers, ember-template-lint, ESLint (and eslint_d), HTMLHint, jscpd, JSHint, madge, markdownlint, markuplint, Oxfmt, Oxlint, Prettier, remark, Secretlint, Standard, Stylelint, textlint, TSLint, and XO.

The tool itself is still a used dependency, and its --config file is still tracked. A module that the tool loads through a flag also stays reachable:

  • A custom formatter, parser, or reporter: eslint -f ./tools/formatter.js, stylelint --custom-formatter ./fmt.js.
  • A local plugin: prettier --plugin=./tools/plugin.mjs, remark --use ./plugins/lint.mjs.
  • A rule directory: eslint --rulesdir ./rules, textlint --rulesdir ./rules. Fallow keeps the source files directly in that directory reachable.

Turn off entry points for a command

Some other commands also read files as data. For example, a code generator that reads src/**/*.ts does not run those files. List such a command in ignoreCommandEntries, and its file arguments do not become entry points:

{
  "ignoreCommandEntries": ["my-codegen"]
}

The command is still a used dependency, and its --config file is still tracked. The option applies to package.json scripts, CI files, and infrastructure files. It also applies when a script call runs the command, such as npm run gen -- src/input.ts with the script "gen": "my-codegen". To turn off entry points from all commands, use ["*"] and declare the real entry points in entry. ["*"] also drops the modules that a linter loads through a flag, such as eslint -f ./tools/fmt.js, so list those in entry too.

Infrastructure entry points

Worker processes and migration scripts often start from a Dockerfile or a CI job, not from an import. Fallow reads these infrastructure files and makes the source files they name entry points, so it does not report those files as unused.

Fallow reads these files:

File typeWhat fallow extracts
Dockerfiles (Dockerfile, Dockerfile.*, *.Dockerfile)RUN node, CMD, ENTRYPOINT, esbuild invocations
ProcfilesProcess definitions (e.g., worker: node dist/worker.js)
fly.toml / fly.*.tomlrelease_command and process definitions
CI pipelines (.gitlab-ci.yml, .github/workflows/*.yml)npx and binary invocations in CI steps

Fallow looks for these files in the project root and in common subdirectories (config/, docker/, deploy/).

The rules for formatter and linter targets and for ignoreCommandEntries also apply here. A CI step such as npx oxlint src/ does not make src/ files entry points.

# Fallow detects scripts/migrate.ts and src/worker.ts as entry points
FROM node:20
RUN node scripts/migrate.ts
CMD ["node", "src/worker.ts"]

Dynamic import resolution

Files that you load with a computed path, such as locale files or icons, are not unused. For import(`./locales/${lang}.json`), the target is not known at analysis time. Fallow converts the pattern into a glob and matches it against the project files.

Fallow supports these patterns:

PatternExampleResolved as
Template literalsimport(`./icons/${name}.svg`)./icons/*.svg
String concatenationimport("./routes/" + path)./routes/*
import.meta.globimport.meta.glob("./modules/*.ts")./modules/*.ts
require.contextrequire.context("./themes", true, /\.css$/)./themes/**/*.css

Fallow marks the matched files as reachable, so it does not report them as unused. This covers locale files, icon sets, route modules, and other directories that follow a naming convention.

A pattern match uses every value export of the matched file, default included. A type export (a type alias or an interface) of a matched file is used only by an import that names it.

Fallow cannot resolve a path that is fully computed at runtime (for example import(userInput)). Add those directories to entry in your config to make them entry points.

Re-export chain resolution

Fallow follows export * chains through any number of barrel files, so an export that you import through a barrel counts as used.

// utils/math.ts
export const add = (a: number, b: number) => a + b;

// utils/index.ts (barrel)
export * from './math';

// src/index.ts (barrel)
export * from './utils';

// app.ts
import { add } from './src';
flowchart TB
  A["app.ts<br/>import ﹛ add ﹜ from './src'"] --> B["src/index.ts<br/>export * from './utils'"]
  B --> C["utils/index.ts<br/>export * from './math'"]
  C --> D["utils/math.ts<br/>export const add ✓"]

Here, fallow follows the import of add in app.ts through src/index.ts and utils/index.ts to utils/math.ts and marks add as used.

Fallow handles these cases:

  • Multi-level chains. Fallow follows export * re-exports at any depth, up to the original declaration.
  • Cycles. Fallow detects circular re-export chains (for example, a re-exports from b and b re-exports from a) and stops without an error.
  • Mixed re-exports. Fallow tracks named re-exports (export { foo } from './bar') and namespace re-exports (export * from './bar').

When two export * sources supply the same name, ECMAScript treats the name as ambiguous, and the barrel does not export it. Until you fix the collision, Fallow does not report the declarations, members, components, or injections that supply the name. Run fallow trace FILE:NAME to see the source files and whether the collision is in the type namespace, the value namespace, or both.

Namespace import narrowing

A namespace import does not mark every export as used. For import * as ns from './module', fallow looks for member accesses (ns.foo, ns.bar) and destructuring (const { foo, bar } = ns) in the importing file. Only those exports count as used.

import * as utils from './utils';

// Only foo and bar are marked as used. baz remains unused
const { foo } = utils;
utils.bar();

This works for static imports, dynamic imports (const mod = await import('./x')), and require (const mod = require('./x')).

Fallow also uses oxc_semantic scope analysis to find imports whose binding the file never reads. If a file has import { foo } from './utils' and never uses foo, that import does not count as a reference to the foo export.

Some patterns use the whole object: Object.values(ns), { ...ns }, for (const k in ns), and rest destructuring (const { a, ...rest } = ns). Fallow cannot tell which members these patterns use, so it marks all exports as used.

Class member detection

Fallow reports public class methods and properties that no code outside the class references. It takes class inheritance, decorators, and framework conventions into account.

class OrderService {
  // Used: called in checkout.ts
  async createOrder(items: Item[]) { /* ... */ }
  
  // Unused: never called outside this class
  private validateItems(items: Item[]) { /* ... */ }
  
  // Excluded: decorator indicates runtime wiring
  @Post('/orders')
  handleCreateOrder() { /* ... */ }
}

Fallow handles these cases without configuration:

  • Inheritance. When code calls a parent class method through the parent type, fallow marks the child override as used.
  • Decorators. By default, fallow skips decorated members. Decorators such as @Get(), @Column(), and @Injectable() mean that a framework calls the member at runtime.
  • Framework lifecycle methods. Fallow never reports componentDidMount, ngOnInit, connectedCallback, and other framework lifecycle methods.
  • Whole-object patterns. Object.values(instance), Object.keys(), spread operators, and for..in loops mark all members as used.

Opting decorators out via ignoreDecorators

Some decorators do not mean that a framework calls the method, for example Playwright's @step("label") or your own @measure, @log, or @retry. List these names in ignoreDecorators. Fallow then checks a method that has only these decorators like an undecorated method.

// .fallowrc.json
{
  "ignoreDecorators": ["@step"]
}

Fallow still skips a method that has any decorator that is not in the list. A method with @step and @Inject stays framework-managed.

See ignoreDecorators for the matching rules and the unmatched-entry warning.

Class member detection uses syntactic analysis and does not run the TypeScript compiler. Fallow tracks member access through the import graph and does not resolve types.

CSS and SCSS tracking

Fallow tracks CSS and SCSS imports, so stylesheets that your code uses are not unused files. It resolves SCSS @use, @forward, and partials (_prefix files). It treats CSS Module class names as named exports and tracks them through styles.className accesses.

For details, see CSS, SCSS, and Tailwind analysis.

Entry-point partial unused exports

An entry point can also export helpers that nothing uses. Before v2.15.0, fallow marked all exports of an entry point as used. Since v2.15.0, fallow reports exports of an entry file that no other module in the project imports. A plugin or the entry config makes a file an entry point.

This helps most with framework convention files, such as Next.js pages and SvelteKit routes. The framework uses specific named exports (such as default, loader, or getStaticProps), and the file can also export helpers that nothing uses.

Fallow never reports an entry-point file as an unused file. It reports only the exports in that file that have zero references.

Cross-reference with duplication

Code that is duplicated and unused is a good place to start the cleanup. fallow dead-code --include-dupes compares dead code findings with duplication analysis. Fallow reports clone instances in unused files, or that overlap unused exports, as combined high-priority findings.

fallow dead-code --include-dupes

When you delete a block that is duplicated and unused, you remove dead code and duplication in one change.

The comparison finds:

  • Clone instances in unused files. When no entry point reaches a file and the file has duplicated code, fallow raises the priority of the duplication finding.
  • Clone instances that overlap unused exports. When an unused export has code that is duplicated elsewhere, fallow reports both findings together.

Circular dependency performance

fallow dead-code --circular-deps runs the full analysis (dead code, dependencies, boundaries, and cycles). All runs are cold, with fallow 2.100.0 on an Apple M5.

ProjectFilesTimeCycles
zod17443ms0
preact24475ms5
fastify28697ms20
vue/core522137ms58
TypeScript38,1462.18s114
next.js20,5523.00s178
astro2,8593.81s42

Fallow finds cycles in the module graph that dead code analysis already builds, so cycle detection does not build a second graph.

See also