npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fxone/prop-flow

v3.0.0

Published

trace an optional prop across every JSX call site and tell whether its `?` is justified

Readme

prop-flow

Does an optional prop's ? actually earn its keep?

For one component prop, prop-flow walks every JSX call site across the whole TypeScript Program and follows pass-through chains up the component tree, across package boundaries, until each path bottoms out in a real source or an omission. Then it says whether the ? is justified, needless, or the prop is fed undefined everywhere — the caller-side dead case that both tsc and knip miss, because the prop is used inside the component, just never passed in.

Why this exists: when a prop is optional all the way up a chain, you can't tell from one file whether the value ever originates anywhere. Removing one ? just chases the type error one level higher; tracing four or five props by hand is an afternoon. This does the climb in one pass.

Installation

$ pnpm add @fxone/prop-flow -D

Or without installing anything:

$ pnpm dlx @fxone/prop-flow <file>

TypeScript is an optional peer dependency — prop-flow deliberately ships none of its own and loads the target project's compiler, resolved from the current working directory, so it always analyses your code with the compiler your code uses. Without a resolvable typescript it exits with a handled error; it will not fall back to a compiler of its own.

Usage

$ pnpm prop-flow <file> [propName] [--tsconfig <path>] [--all-props] [--json]

Run it from the project root. Both the compiler lookup and every relative <file> resolve against the working directory of the process — and `pnpm -C

| argument | meaning | | -------------- | ------------------------------------------------------------------------------------------------------------------- | | <file> | a .ts/.tsx file containing the component(s) to inspect | | [propName] | one prop; omitted → every optional prop of every component exported from the file | | --tsconfig | override the auto-discovered tsconfig — use the broadest "solution" config so call sites in other packages are seen | | --all-props | inspect required props too — reported only where they carry a constant value | | --json | machine-readable output |

$ pnpm prop-flow src/Button.tsx title

tsconfig: tsconfig.json  (412 files in Program)
file:     src/Button.tsx

justified         Button.title
   passes=2  omits=2  ambiguous=0
     real        src/App.tsx:12:7 (string literal)
     omit        src/App.tsx:13:7
     passthrough src/Card.tsx:9:5 → Card.action
   → genuinely sometimes-absent. The `?` is correct.

Batching over files

Under --json an invocation that names a file always emits exactly one object on stdout — a report, or {"error": "…"} for a handled failure. So a loop over the files you changed stays parsable even where one of them fails, and the whole batch goes through a single jq:

$ for f in $CHANGED_TSX; do prop-flow "$f" --json; done | jq -s '
    [ .[] | select(.error | not) | .props[]
      | select(.verdict == "caller-dead" or .verdict == "unnecessary-optional"
               or .verdict == "manual" or .constant != null) ]'

An empty array means nothing was found — not that a filter missed it. Do not filter the text output instead: the verdict labels are deliberately mixed case (justified reads quietly, CALLER-DEAD does not), so a grep anchored on one case silently drops the other, and an empty result then means either "nothing found" or "wrong pattern".

Each invocation builds its own Program, which is the bulk of the runtime (~6 s on a 4 000-file monorepo). So batch by file — passing a propName saves nothing, and re-running a file to reformat its output costs a second Program. Capture once.

The one thing --json does not wrap is the usage text: --help, and exit 1 for an invocation with no file at all, print it plain. Both are answers to a person rather than to a pipeline — and a loop that passes a file every time cannot reach either.

Verdicts

| verdict | meaning | | ---------------------- | ------------------------------------------------------------------------ | | justified | some call sites pass it, some omit it → the ? earns its keep | | unnecessary-optional | every call site passes it → could be required | | caller-dead | no call site passes it → optional and always undefined | | unused-component | the component itself has no call sites in the Program | | manual | an unreadable spread or a contested override blocks a static conclusion | | required | the prop has no ? to judge — listed only for its constant value |

Exit codes: 0 success, 1 nothing to do (usage printed), 2 a handled failure (message on stderr, or {"error": …} on stdout under --json).

Nothing to analyse is a success, not a failure: a file with no exported component — a route module, a props-less page — and a component whose props are all required both come back with an empty report and exit 0. They are distinguishable: components is 0 in the first case. Exit 2 is reserved for what genuinely blocked the analysis (a missing file, no resolvable compiler, a tsconfig that does not span the file), so a loop over changed files can stop on a real failure without stopping on a page component.

Constant values

A prop that is passed the same value at every call site carries no information: the value can be inlined and the prop dropped. That question is orthogonal to the ? — a prop can be justified (some call sites omit it) and still be constant everywhere it is passed, which is the most interesting combination of all. So it is reported as its own field rather than as a verdict:

justified         Chip.variant
   passes=2  omits=1  ambiguous=0
   constant="danger"  coverage=passes
     real        src/App.tsx:4:7 (string literal)
     real        src/App.tsx:5:7 (string literal)
     omit        src/App.tsx:6:7
   → genuinely sometimes-absent. The `?` is correct.
   → every passing call site sends "danger"; the value could be inlined.

Values are read off the type, not off the syntax, so size="sm", size={'sm'}, a const SIZE = 'sm', an enum member and a property of an as const object all resolve to the same value — and anything the checker cannot pin to a single literal (a call, a parameter, a widened let) leaves the claim unmade. <C dense /> counts as true.

Two coverages: passes means every call site that passes the prop agrees; all means nothing anywhere sees another value — either there are no omissions, or the component's own binding default is that same value, so the omissions land on it too. all is the case where the prop can go away entirely.

Nothing is reported below two passing sites — with one, "always the same value" is trivially true. And constant does not mean wrong: variant="danger" on the two delete buttons is constant and correct, which is why the hint stops at "could be inlined".

--all-props widens discovery to required props, not the report: a required prop shows up only when it actually carries a constant value, under the required verdict. Without the flag the output is exactly as it was.

API

The same analysis is available programmatically:

import { analyseProps, formatText } from '@fxone/prop-flow';

const report = analyseProps({ file: 'src/Button.tsx', prop: 'title' });
process.stdout.write(formatText(report));

analyseProps accepts { allProps, cwd, file, prop, ts, tsconfig } and returns a Report; passing ts injects a specific compiler instead of resolving one from cwd.

Limitations

Pass-throughs are followed through plain identifiers and props.x member access, including inside render callbacks — a props.x in items.map(…) is still traced to the surrounding component. When a level of the chain binds its own default, an omission at its call sites is counted as a pass of that default rather than as an omission at the leaf: Relay({ size = 'lg' }) forwarding size feeds 'lg' down, not undefined. Those absorbed omissions are listed individually, at the call site where the default fires:

NEEDLESS ?        Hop.size
   passes=2  omits=0  ambiguous=0
   constant="lg"  coverage=all
     passthrough src/Relay.tsx:9:10 → Relay.size (omissions fall back to its default)
     real        src/App.tsx:14:7 → Relay.size (the default fires here)
     real        src/App.tsx:15:7 → Relay.size (the default fires here)

Otherwise the counts are of leaves, not of lines: one pass-through site can stand for a whole subtree of passes and omissions below it.

A constancy claim needs every call site to be readable. One manual site sinks it — an unreadable spread could be carrying any value at all — as does a single value the checker cannot pin to a literal.

A spread is only ambiguous when it can actually reach the prop. {...x} whose type provably lacks the prop is skipped; {...props} and {...rest} are followed one level up, and a spread of an object literal (or of a const bound to one) is read key by key. What stays manual: a spread whose type cannot answer the question (any, Record<string, unknown>, a union that carries the prop in only some constituents), and an optional prop in a spread that contests an earlier value — both outcomes are possible at runtime, so neither is concluded. JSX ordering is respected throughout: in <C title="x" {...props} /> the spread wins.

Optional props a component only inherits from a dependency — the ~250 DOM and ARIA props behind React.ComponentProps<'button'>, say — are not reported. A verdict on them is true but useless: the ? is not yours to drop, and they bury the props that are. A prop redeclared in your own type is still reported.

A pass-through that climbs into a function which is called rather than rendered — a renderX({ … }) test helper, typically — also stays manual: its callers exist but are invisible to a JSX walk, and counting them as zero would report a live prop as caller-dead.

prop={undefined} counts as an omission — it is an omission dressed up as a pass, so a prop that is only ever fed undefined still comes out as caller-dead. A conditional expression that can evaluate to undefined counts as a real source — the one false positive the tool accepts on purpose.

Components are picked up from export function C, export const C = … (including memo() / forwardRef() wrappers), export default function C and export { C } at the bottom of the file. A component re-exported through a barrel is still found at its call sites, but must be inspected in the file that declares it.

An exported useX taking an options object is skipped. It is indistinguishable from a component to the AST and has no JSX call sites, so every one of its options would come back unused-component — a statement about the walk, not about the hook. Only discovery is narrowed: a prop that passes through a hook on its way down is still traced, and still reported at the component that declares it. The use prefix is the one naming convention safe to key on; lower-cased components are rare but legal, so PascalCase is not.