Dead Code Cleanup with Knip
Every codebase accumulates cruft. A function written for a feature that never shipped. A type exported “just in case.” A whole component superseded by a better approach but never deleted.
We’ve been fighting dead code at Ditto for over a year now. This is the story of how our tooling evolved—from manual grep sessions to ts-prune to our current setup with Knip.
The Old Days: ts-prune
Back in November 2025, we used ts-prune to hunt dead code. It found 60+ potentially unused exports. We deleted some obvious ones: a TableView component that got replaced months earlier, a utility file nobody imported, a test file for a removed feature.
But ts-prune had limitations. It flagged false positives constantly—especially for dynamically imported components. We spent more time triaging its output than fixing actual issues.
Enter Knip
Knip is ts-prune’s spiritual successor. It understands your project’s entry points, traces actual usage through dynamic imports, and gives you actionable output instead of a wall of maybes.
We recently ran Knip and found 73 issues: 32 unused exports and 41 unused types. Here’s what we did about it.
Our initial run:
$ bun run knip
Unused exports (32)
Unused exported types (41)
Broken down, we found:
- 4 completely dead functions that could be deleted outright
- 6 functions exported but only used internally (didn’t need to be public)
- ~40 types exported but never imported anywhere
- 3 files (component + utility + test) with zero imports
The Cleanup
Phase 1: Delete What’s Actually Dead
The easy wins. Functions nobody calls:
| File | Deleted | Why |
|---|---|---|
api/modelServices.ts | getImageModel | Superseded by paginated version |
lib/time.ts | SUPPORTED_CONTENT_TYPES | Never imported |
lib/brand-icons.tsx | SiGithub, SiInstagram, SiYoutube | Never imported |
(We’d already deleted the TableView component and its utility file back in November during our ts-prune cleanup. But the CSS survived—more on that later.)
Phase 2: Unexport Internal Functions
This was trickier. Knip flagged these functions as unused, but they were used—just internally within the same file:
// Before: exported but only used by uploadImage in the same file
export async function createPresignedUpload(/* params */) {}
// After: keep the function, remove the export
async function createPresignedUpload(/* params */) {}
Six functions got this treatment across our API layer. Same functionality, cleaner public API surface.
Phase 3: Unexport Dead Types
This was the bulk of the work. Types like ParamsLongTermMemoriesV2, WhatsNewPreferences, CameraType—all exported, none imported. We unexported about 40 of them across 16 files.
One gotcha: after unexporting types in memories.ts, TypeScript complained. Turns out CreateImageResultSchema (a Zod schema, not a type) was still imported by a component. We kept the schema exported but removed the type—the component only needed runtime validation, not the TypeScript type.
Phase 4: Configure for Reality
Knip can’t trace dynamic imports or lazy-loaded components. It flagged several components as unused when they’re actually loaded via React.lazy() or Solid’s createResource.
Two options:
- Add files to Knip’s ignore list
- Add
/** @public */JSDoc tags to intentional exports
We chose option 2 where possible. Tags are co-located with the code they describe, and future dead code in those files still gets detected.
/** @public - Loaded dynamically via lazy import in App.tsx */
export default function MemoryVisualization() {}
Our final knip.json only ignores truly special cases: vendored code, auto-generated UI components, and scripts.
The CSS Problem
Here’s the thing about deleting components: the CSS doesn’t know. When we deleted TableView.tsx back in November, we forgot to check its styles. Months later, we ran a CSS analysis and found 200 lines of orphaned styles in MemoryNetwork.css—all the .memory-table, .memory-node, .memory-view-button classes that styled a component that no longer existed.
The lesson: dead TypeScript is easy to find. Dead CSS is not. Knip doesn’t check your stylesheets.
We also found 4 CSS variables that were referenced but never defined:
--primary-rgb(used in rgba() calls)--primary-hover--gradient-start-color/--gradient-end-color
These were silently failing, probably falling back to browser defaults. Not breaking anything visibly, but definitely wrong.
CI Integration
The cleanup is only useful if you maintain it. We added Knip to two places:
Pre-commit hook (catches issues before they hit the repo):
# .husky/pre-commit
bun run knip
CI pipeline (catches issues in PRs):
- name: Install dependencies
run: bun install
- name: Run knip
run: bun run knip
The bun install step matters—Knip analyzes your Vite config, which imports dependencies. Without installation, it fails on missing modules.
Results
Before: 73 warnings After: 0 warnings
More importantly:
- Public API surface is smaller and clearer
- 200+ lines of dead CSS identified for removal
- New dead code gets caught automatically
- Developers can trust that exports are actually used
Lessons Learned
1. Exported doesn’t mean used. We had a habit of exporting types “in case someone needs them.” They rarely do. Export when there’s an importer.
2. Internal-only functions don’t need exports. If a function is only called within its own file, don’t export it. This seems obvious, but it’s easy to forget when you originally wrote it as a public API.
3. Dynamic imports need annotation. Any static analysis tool will struggle with import() expressions and lazy loading. Document your intentional exports.
4. CSS outlives the components it styles. TypeScript catches dead .tsx files. Nothing catches dead .css rules (unless you add a tool for it). Consider running PurgeCSS or similar periodically.
5. CI is the enforcement mechanism. The cleanup is a one-time effort. The CI check is what keeps the codebase clean over time.
The Tools
- Knip — Dead code, unused dependencies, orphaned files
- Biome — Fast linter and formatter (catches different issues than Knip)
- tsgo — TypeScript type checker (faster than tsc)
For CSS specifically, we don’t have automated detection in CI yet. Manual audits for now.
Dead code accumulates slowly, then suddenly. One unused export becomes ten becomes fifty. Running Knip quarterly (or better, on every commit) keeps the entropy in check.
If you haven’t run a dead code analysis on your codebase recently, try it. You might be surprised what you find.
Questions about our tooling setup? I’m happy to chat. Reach out anytime.
— Peyton