norefs loads your project with ts-morph and runs two passes.
--entry adds any the build does not name. Test, spec, stories, bench, and config files are their own entry points, so they are never reported either — and their members are not analyzed: a fixture type with an unread field is noise, not dead code. A file counts as a harness by its name — card.test.ts, pick.test-d.ts and groupBy.test-prop.ts all carry the word with the suffix its tool gives it, and benchmark.js is the whole name — or by the directory over it: __tests__, __mocks__, bench, benchmarks, test-d, and any name that puts a word and a separator before test or tests, wrapped in double underscores or not (type-tests, __performance_tests__).Code beside the program — a tsconfig decides what a run holds, and projects leave real code out of it: an exclude that names the tests, a files list of one declaration file whose implementation is JavaScript, a scripts directory nobody compiles. Those files are read for three things and nothing else: which project files they import, which names they take, and which packages they name. So an export a sibling test imports is used, a file only a script imports is reached, and a package only lib/*.js loads is not dead — while nothing in those files is ever analyzed or reported. In a workspace, the other packages are read the same way — a tsconfig names one package and the repository is the project, so where a tsconfig sits is a layout decision rather than a boundary of the code. A sibling is read for what it takes from the package under analysis and for nothing else: its own imports say nothing about this package’s manifest.
node_modules, the dot-directories, each package’s outDir, and the directory the manifest’s main, module, browser or exports points at are left out of the reading: build output is the same code twice, and yesterday’s copy of it must not keep today’s dead code alive. A tsc build names its output in the tsconfig; a bundler’s is named nowhere the compiler reads, which is why the manifest is read for it too. A directory holding a file the program holds is source by demonstration, whatever the manifest calls it.
export default { … }, export default class { … } — is checked too, and reads dead default export: nothing local can use it, since there is no name to use, so it is imported or it is dead. The exception is a harness file, where the default export is how a tool takes its input — a vitest config, a storybook story — and no import will ever name it. A declaration that is used inside its own file but never imported is reported as over-exported: the export keyword is dead even though the code is not, so the fix is to drop the keyword, not to delete the declaration. Both claims are about every file the project keeps, not only the ones the tsconfig holds — a name a file beside the program imports is used, and neither claim is made about it. Declaration files the project wrote are scanned too: an exported type in src/api.d.ts that nothing imports is as dead as one in src/api.ts, and its members answer the same way. A .d.ts a package ships is never scanned, and neither is one nothing imports. The public API is never reported: every declaration an entry file exports — resolved through re-export chains, export * and export * as ns included — is exempt, members and all, because its consumers live outside this program. The exemption follows the types those declarations name, and the types those name, until nothing new turns up. A consumer never imports an arm of an exported union by name; it holds one. It never imports the interface a public function returns; it calls the function and reads through what comes back. Only the surface counts — a type named inside a function body is one no caller can hold, so a body is where the walk stops.import * as ns binding whose every use is a property read, its zero-reference exports are reported this way, because the namespace object may still be consumed dynamically. And when a TS namespace N { … } is used, its exported members whose references never leave the namespace body are reported this way too. A namespace object that leaves as a whole — import * as schema from './schema' then orm(db, { schema }) — is a different story: the consumer walks its keys and no reference search can see it, so that module is exempt down to its last member, exactly like public API.ipcMain.handle('recipeBox:load', …)) whose every sender this report deletes. norefs finds the bridge your own .d.ts declares without being told; name any other boundary — HTTP routes, a socket bus — with the boundaries config key. The registration keeps the handler “used”, so no reference-based analysis will ever flag it — including the next norefs run, once you remove the wrapper that sends to it. The finding lands on the handler’s own file and line, while it is still visible. A sender counts as dying only when its own declaration goes: the method that holds the channel string, not the class around it, and never an over-exported declaration, whose fix drops a keyword and deletes nothing. It obeys --scope and the suppression comments like any other finding; the note on the wrapper names the far side either way.package.json lists; and an entry whose section does not match how it is used. Peer and optional dependencies exist for consumers and are never reported. @types/* packages are consumed by the compiler and pair with their base package. Path aliases, node builtins, and relative imports never count as packages.A finding at a higher level swallows the findings inside it: an unused file hides its exports and members, an unused export with zero references anywhere hides its members, and a type losing every member folds them into its one becomes empty finding. One line per problem, not fifty.
Every finding carries a verdict: the claim it makes, with its safety profile. “Unused” is not one claim — it is six:
--fix does.--fix does.count += 1, or count++ on a line of its own) writes; one whose value is handed on (const n = count++) reads; a key the source computes writes too when it is assigned through — shelf[slot] = 4 names its members as surely as writing them out would, and the key’s type says which; a typed write — the member has no references at all, but a write elsewhere flows into a use whose type declares it; an unverified name match — a write the analysis could not type either way, and only while the matches are few enough to be a lead worth walking. Past a handful the count is all it means, and the verdict falls back to dead with that count in the evidence. A name match that provably feeds a different type is discarded instead of reported, so a member is never protected by how popular its name is elsewhere. A read that reaches the value through another declaration — what satisfies and as const leave behind — is a read, and keeps the member off this list. So is a write this run cannot follow: a literal handed to a body norefs does not hold — a package, an ambient declaration, an overload with no implementation — is read at the far end, and the member keeps the answer it had before. --fix-unsafe retires a proven write-only member together with the writes that prove it, where each of them is one edit — an object-literal property, a JSX attribute. An assignment statement is not: its right-hand side may be doing work that has to stay. A member written that way is reported and left for you.JSON.parse, JSON.stringify, structuredClone, postMessage), a call on something a project .d.ts declares (an IPC bridge, a preload global), or any untraced result (any/unknown) pinned to the type by assertion — directly or through a containing type. The members document a wire format; deleting them destroys the documentation, not the data. When a twin of the type sits across the boundary, the two findings merge into one contract and each names the other side.(api as WithDispatch).dispatchFromDevtools is a person telling the compiler what a value is where the compiler knows nothing, and the read lands on the shape the cast names — never on the declaration the value really came from. A reference is not a read either: two copies whose builders both only fill the member in shadow nobody, and the evidence says which of the three it found. The member is probably alive through the duplicate, and the real finding is the duplication: merge the twins, don’t delete the member.tests/ that only tests import is a fixture doing its job. A real and common category of dead code, and never auto-fixed: the fix is deleting the code together with its tests, and only a human deletes tests. --production is the stricter cut, where the tests are not there at all.Each soft verdict prints its evidence — the twin that reads the member, the boundary the type crosses. --fix only applies dead and over-exported findings; the rest wait for --fix-unsafe or your judgment.
The member pass looks for six kinds of member owners:
interface declarationstype aliases, and any inline object type (parameter types, return types, variable annotations — this covers React props like function Foo({a}: {a: string}))enum declarationsconst Timeouts = { … } as const, the enum modern TypeScript writes. Timeouts.SAVE_DEBOUNCE reads a member the way an enum member is read, so a member nothing reads is dead the same way. Plain const x = { … } counts too, and a property written the short way ({ spareJar }) is a property like any other; a declared shape does not, because the type that declares it is what gets reported (see below)class declarations (properties, methods, accessors, static members, and constructor parameter properties)A function is read through every return it has. Each literal it hands back is a shape of its own, so if (wide) return { handle, deadWide }; return { handle, deadNarrow } reports both dead keys — and a key more than one branch writes is reported only when every one of those branches is unread, because two branches of one shape share a single set of declarations. It is reported once then, on the branch that writes it first: one death, told once, on the line a reader edits before the other. --fix removes that copy and comes straight back for the next. A return of anything but a literal leaves the function alone.
Both object-literal owners are read to their full depth. A literal nested inside another is a shape of its own, so const cfg = { oven: { tray: 'steel', deadRack: 'wire' } } reports deadRack in const `cfg.oven` — but only where every read of oven keeps the value local. A property can hold one shape per element too: { cards: [{ title, deadNote }, { title, deadNote }] } reports deadNote on both cards, as long as every element stays local — read through a callback written on the spot, a for…of binding, or an index. The elements answer together, so a name any one of them holds a read on is alive on all of them. A read that hands the whole inner shape onward stops the descent there, and so does a property nothing reads: that property is the finding, and the members under it would tell one death twice. Limitations has the full rule.
For each property it finds, it asks norefs’ own project-wide reference index (via findReferencesAsNodes) whether anything reads it — one pass over every identifier, rather than a language-service query per declaration; see Speed. No references beyond the declaration itself means the property is unused.
Because the check is reference-based, it follows structural typing correctly — v.x resolves back to interface A { x: number } even without an explicit cast. See Limitations for where that breaks down.
When every member of a named interface or type alias is unused while the type itself is still referenced, the member findings fold into one: interface `X` becomes empty: all 6 members are dead. That is one logical fact, so it is one finding, carrying the most cautious verdict of the members it swallowed. Removing the members would leave an empty interface X {} behind, and only you know whether its consumers should go too — --fix never touches these. An interface that extends another is exempt — empty, it still works as an alias.
A shape written inline on a property or on a binding folds the same way, and reads property `labels` becomes empty: all 2 members are dead. { labels: { deadColor, deadFont } } has no declaration to answer for the inner shape — the property is what a reader would delete, so the property is the finding, whether the shape is written as a value or as a type. The property itself is still read: that is the only reason norefs looked inside it, since a nested shape is only read member by member where every read of the holding property keeps the value local. So the fold always leaves a read behind that now reaches nothing, and removing the property means removing that read too — a human’s call, exactly like an emptied interface’s consumers. Without the fold, --fix would delete the members and leave labels: {} sitting there: dead, and invisible to the next run.
A const box = { … } that loses every member folds onto the binding for the same reason — const `box` becomes empty: all 2 members are dead — but only while something still reads box. A binding nothing reads is not a fold: the members are reported one by one, and the cleanup pass takes the whole declaration with them. Nothing outlives that removal, so nothing needs your judgment.
--only namesMost --only names read as the checks above: files, exports, types, members, dependencies, unlisted, misplaced, stranded. Three are less obvious, so here they are defined:
--only name |
What it reports |
|---|---|
ns-exports |
An unused export whose namespace — a TS namespace or an import * as binding — is used, so the export may still be consumed dynamically |
ns-types |
The same, for an exported type |
empty-types |
A still-referenced type — or a property or binding holding an inline shape — that becomes empty once its unused members go: the folded becomes empty finding above |
Every finding norefs makes is relative to a question. The default question is “does anything in this repository use it?”, and the tests count — a member only a test reads is labelled test-only, not dead, because deleting it breaks something real.
--production asks the stricter question: what is left standing if the tests were not there at all?
norefs --production
Test, spec, stories, bench and config files — and everything under a harness directory — are treated as absent. Three things follow, and they are the whole definition:
test-only.test-only becomes plain dead.devDependencies fall outside it too: they exist to build and test. So does the misplaced-dependency check, which needs both halves of the code to decide anything. A dependencies entry only the tests import is simply unused here.
It never combines with --fix. A production finding is dead to the shipping path and may be perfectly alive in the tests this run ignored — deleting it breaks them. That is the same reason test-only findings are never fixed either: the fix is deleting the tests too, and only you do that. norefs --production --fix is a usage error, exit code 2.
The two modes answer different questions, so run both: the default one to find what nothing uses, --production to find what only the scaffolding is holding up.