Analyse a TypeScript+React codebase. Find every component prop, find every JSX usage, and report any prop that no caller passes.
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.
unused-props ./src --tsconfig ./tsconfig.jsonScanned 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-spreadpnpm add -D unused-props
# or
npm install --save-dev unused-propsRequires Node ≥ 20.
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.
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));
}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 intersection —
type Foo = { ... } & Bar. Members of the inline{ ... }are own; members of any referencedBarare skipped. - Type references outside your
<src-dir>— skipped entirely. This is whyReact.HTMLAttributes, MUI props, etc. don't appear in findings.
Move a prop into the child interface or the inline literal to monitor it.
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}orpriority={"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
undefinedto 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.
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.
- Spread props (
{...spread}) — when a caller spreads, we can't statically tell what's forwarded. Each finding carries bothspreadCallersandnonSpreadCallersso 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-spreadfails the run when any finding has spread callers. - Dynamic JSX —
React.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}wheresomeVar: "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.
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.
This repo uses Changesets to manage versions. Any change that affects published behaviour requires a changeset, declaring the bump level:
pnpm changesetPick (while on 0.x):
patch— bug fixes and internal-only refactors.minor— additive features and breaking changes.major— avoid. Changesets jumps anymajorchange on a pre-1.0 package straight to1.0.0. Pre-1.0 convention is to treat everything as potentially breaking; record breaking changes asminoruntil 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)MIT