Skip to content

penx/unused-props

Repository files navigation

unused-props

Analyse a TypeScript+React codebase. Find every component prop, find every JSX usage, and report any prop that no caller passes.

How?

It parses your TypeScript source with ts-morph, enumerates each component's own declared props (skipping members inherited from extends clauses and from types declared outside your source directory), then scans every JSX usage to see which props are actually passed. Anything left over is reported.

Usage

unused-props ./src --tsconfig ./tsconfig.json
Scanned 48 components with declared own props.
Found 2 component(s) with at least one prop never passed by a caller (excluding ignored callers).

src/components/Button.tsx:12  Button
  4 caller(s)
    - variant  (optional)

src/components/Card.tsx:8  Card
  3 caller(s), 1 with {...spread}
    - elevation  (optional)
  Warning: 1 caller(s) use {...spread} — listed props may be passed through.

Full JSON report: unused-props.report.json

Pipe-friendly:

unused-props ./src --no-report --json-only | jq '.findings[] | .component'

CI use — fail the build on any finding, treat spread callers as errors:

unused-props ./src --error-on-spread

Install

pnpm add -D unused-props
# or
npm install --save-dev unused-props

Requires Node ≥ 20.

API

CLI

unused-props <src-dir> [options]
Flag Default Notes
-t, --tsconfig <path> tsconfig.json Used to load the project's source set.
-r, --report <path> unused-props.report.json Write the full JSON report to this path.
--no-report Skip writing the report. With --json-only, JSON is printed to stdout instead.
--ignore-callers <glob...> **/*.stories.{ts,tsx}, **/*.test.{ts,tsx}, **/*.spec.{ts,tsx} picomatch globs for files whose JSX usages should NOT count as real callers. Matched against the path relative to <src-dir>. Pass your own patterns to override; pass an unsatisfiable glob (e.g. __never__) to count every caller.
--json-only false Suppress human-readable stdout output.
--error-on-spread false Exit with code 2 if any finding has callers using {...spread}.
--error-on-unused-values false Exit non-zero if any value-bearing prop (literal union, boolean, enum) has unused declared values. By default these are informational.
--error-on-redundant-defaults false Exit non-zero if any prop's destructuring default is never overridden by a real caller. By default these are informational.

Exit codes: 0 clean · 1 findings (or --error-on-unused-values triggered) · 2 invalid args or --error-on-spread triggered.

Argument parsing is provided by commander.

Programmatic

import { scan, type ScanOptions, type ScanResult } from "unused-props";

declare function scan(opts: ScanOptions): ScanResult;

interface ScanOptions {
  projectRoot: string;            // file paths in findings are relative to this
  srcDir: string;                 // absolute path to your source root
  tsconfigPath: string;           // absolute path to the tsconfig used by ts-morph
  ignoreCallerPatterns: string[]; // picomatch globs; matched relative to srcDir
}

Example:

import { scan } from "unused-props";

const result = scan({
  projectRoot: process.cwd(),
  srcDir: "/abs/path/to/src",
  tsconfigPath: "/abs/path/to/tsconfig.json",
  ignoreCallerPatterns: ["**/*.stories.{ts,tsx}", "**/*.test.{ts,tsx}"],
});

for (const f of result.findings) {
  console.log(f.component, f.unusedProps.map((p) => p.name));
}

What counts as an "own" prop?

The tool reports only props you declared yourself. Members that flow in via type composition are treated as inherited and skipped:

  • Interface with extends — only direct members of the interface are reported. Anything from the parent (e.g. extends HTMLAttributes<...>) is skipped.
  • Type alias intersectiontype Foo = { ... } & Bar. Members of the inline { ... } are own; members of any referenced Bar are skipped.
  • Type references outside your <src-dir> — skipped entirely. This is why React.HTMLAttributes, MUI props, etc. don't appear in findings.

Move a prop into the child interface or the inline literal to monitor it.

Unused prop values

In addition to unused props, every run also reports unused values within value-bearing prop types: string/number literal unions, boolean, and TS enums. If priority: "primary" | "secondary" is always passed "primary", the "secondary" branch is reported — the type is narrower than its usage and the abstraction can be tightened.

null and undefined count as first-class candidate values when they appear in the union (1 | undefined | null | "example" reports any of the four that no caller passes). Optional props (prop?: T) implicitly include undefined as a candidate.

What counts as "passing" a value:

  • A literal JSX attribute: priority="secondary", count={2}, enabled, enabled={true}, value={null}.
  • A reference whose type resolves to a single literal: priority={Size.S} or priority={"x" as const}. Variables typed as the full union (prop: "a" | "b") are treated as dynamic — the finding is annotated, the listed values may actually be passed.
  • A destructuring default (({ priority = "primary" }) => …) — the default value counts as passed.
  • For optional props with no default, a caller that omits the prop contributes undefined to the "passed" set.

By default these findings are informational and don't change the exit code. Pass --error-on-unused-values to fail the run when any are present.

Redundant prop defaults

Separate from the value-coverage analysis above, the scanner also reports destructuring defaults that no real caller ever overrides. For a component like:

const Header = ({ prefix = "Hello, ", children }: HeaderProps) =>
  <h1>{prefix}{children}</h1>;

if every caller either omits prefix or passes prefix="Hello, " explicitly, the default is doing no real work — the prop could be removed entirely (or the default dropped).

The analysis only fires when at least one caller actually exercises the default (otherwise the prop would just be unused, which is the existing unused-props analysis). Findings are skipped if the prop is already reported as fully unused.

By default these findings are informational. Pass --error-on-redundant-defaults to fail the run when any are present.

Known limitations

  • Spread props ({...spread}) — when a caller spreads, we can't statically tell what's forwarded. Each finding carries both spreadCallers and nonSpreadCallers so you can triage: if any caller wrote attributes explicitly without the unused prop, that's strong evidence the prop is dead even if other callers spread. --error-on-spread fails the run when any finding has spread callers.
  • Dynamic JSXReact.createElement(...) and components rendered via a variable (const C = condition ? A : B; <C />) aren't traced.
  • Prop flow through helpers / HOCs — if a wrapper function receives props and forwards them to the real component, the wrapper isn't JSX so it doesn't count as a caller.
  • Generic components — type parameters may not resolve to a concrete set of props; coverage may be partial.
  • Components without an explicit prop type annotation — skipped; we can't enumerate props from inference alone.
  • Components only used by ignored callers — skipped. Use knip or similar to find truly unused components.
  • Dynamic prop values — for the unused-values analysis, an expression whose type doesn't narrow to a single literal (e.g. prop={someVar} where someVar: "a" | "b") is treated as dynamic. The finding still lists "unused" values but is annotated with a warning that they may actually be passed at runtime.

Development

pnpm install
pnpm test       # vitest run
pnpm typecheck  # tsc --noEmit across src + tests
pnpm build      # compile to dist/

Fixtures live in tests/fixtures/, each a minimal TS+React project exercising one scenario.

Versioning

This repo uses Changesets to manage versions. Any change that affects published behaviour requires a changeset, declaring the bump level:

pnpm changeset

Pick (while on 0.x):

  • patch — bug fixes and internal-only refactors.
  • minor — additive features and breaking changes.
  • major — avoid. Changesets jumps any major change on a pre-1.0 package straight to 1.0.0. Pre-1.0 convention is to treat everything as potentially breaking; record breaking changes as minor until we intentionally cut 1.0.

Once at 1.0+, switch to standard semver: minor for additive, major for breaking.

Changesets accumulate in .changeset/ and are consumed at release time:

pnpm changeset version   # bumps package.json + writes CHANGELOG.md
pnpm changeset publish   # publishes to npm (runs prepublishOnly → build)

License

MIT

About

Find unused React props

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors