Knip: dead code when AI agents write faster
AI agents speed up how much code we generate. Knip helps find files, exports, and dependencies nobody uses anymore — and turn that hygiene into an automatic project rule.
Over the years I have repeatedly found codebases full of functions nobody can quite explain, abandoned variables, exports that still exist even though nothing imports them, and files that stay in the project simply because there was never a concrete reason to delete them.
It is not always carelessness. It is the natural result of iterating: you replace an abstraction, move a responsibility, leave a helper “just in case”. With AI agents, that speeds up: code grows faster, and so does the code nobody cleans up. Over time the repository mixes what the application uses with what it once used.
TypeScript and modern IDEs help with part of this. Unused variables, imports, or certain functions are easy to spot visually. Linters can also flag unused code inside a file.
Still, I always felt something was missing for a broader question:
Which parts of my project are still actually needed?
Knowing that a variable is unused inside a file is one thing. Analyzing relationships across files, exports, entry points, and dependencies to decide what still belongs to the used code is another.
Where Knip fits
Knip addresses that second part of the problem.
According to its documentation, it builds a project graph from entry files and follows relationships between imports, exports, and files. From that graph it can identify files nothing reaches, exports no other file imports, and dependencies that are not used.
That changes the question. We are no longer only asking “is this variable used?”, but “does this still belong to something my application actually uses?”.
That distinction matters. Knip does not replace ESLint, TypeScript, or similar tools. Its docs make clear it is not aimed at unused variables and imports within a single file; for that it points to tools like ESLint, Biome, or oxlint. Knip operates at a different scale: the whole project.
When I integrated it into one of my own projects, the distinction became concrete. TypeScript warned me about local issues. Knip showed structural ones: files unreachable from entries, exports with no importers, and dependencies with no consumers.
It also forced me to read false positives carefully. If Knip marks something unused, it is usually saying: “from the entries I know, nothing reaches here”. Sometimes the finding is real; sometimes you need to teach it the right graph boundary — entries, plugins, or specific exceptions, like exports from a UI library that are not all consumed yet.
Dead code and unused exports
It is worth not oversimplifying the impact of an unused export.
It is not accurate to claim, without nuance, that “one extra export makes the application slower”. The effect depends on how code is compiled, bundled, and executed.
Knip’s documentation notes that unused exports can increase bundle size when tree-shaking does not eliminate them correctly, while also adding noise to the codebase, making maintenance and navigation harder, and increasing work for tools that analyze the code.
To reason about this more precisely, it helps to separate layers:
- Code in the repository. It exists, is versioned, is read in PRs, and shapes how the system is understood.
- Code that enters the compilation process. TypeScript, the bundler, and other tools may need to parse, type-check, or resolve it even if nobody executes it.
- Code that reaches the bundle. Only part of the production graph ends up packaged.
- Code tree-shaking can remove. A modern bundler tries to drop unreferenced exports, but it is conservative: if it cannot prove something is safe to remove, it usually keeps it.
- Code that affects runtime. What is ultimately downloaded and executed on the client or server.
Tree-shaking helps, but it does not make dead code irrelevant. Knip is clear about this in its FAQ: tree-shaking is a build-time optimization; Knip is a project linter. The first can shrink the bundle. The second targets the cost of maintaining and auditing code the repository no longer needs.
What matters most to me: turning it into a rule
What interested me most about Knip was not only finding dead code, but turning that into an automatic condition: local commands, Git hooks, CI/CD, or GitHub Actions.
Knip’s docs recommend using it in CI precisely to prevent unused dependencies, exports, or files from coming back over time. It can also exit with code 1 when it finds issues.
This connects to an idea I already wrote about in Git Auditor: Native Shell Hooks for a Minimum Viable Team Standard: good practices last longer when they stop being reminders and become automatic rules. A hook or CI job does not replace human judgment; it reduces the odds that cleanup is left “for later”.
Knip also offers auto-fix (--fix, and --allow-remove-files when appropriate). That speeds cleanup up, but it does not remove the need to review. Its own docs warn against running auto-fix before the configuration is solid: if the graph is incomplete, you can delete code that is still used.
The AI agent context
We increasingly use AI agents to write, modify, and refactor code. That changes how fast a codebase can grow.
An agent can create an abstraction, later replace it with another, change an architecture, or introduce new functions without necessarily deleting everything that came before. The result is a codebase that evolves faster — and can accumulate unnecessary code faster too.
If we delegate more code writing to agents, we also need stronger automatic audits. The point is neither blind trust nor line-by-line review: agents can move quickly while other tools verify that the result still meets quality rules.
Automate before trusting memory
Keeping a codebase clean should not depend only on someone remembering to clean it up.
For years we added linters, formatters, tests, and other validation mechanisms precisely to turn good practices into automatic rules. I think dead code should be treated the same way.
And that becomes even more relevant when we work with AI agents.
If agents let us write code faster, we also need tools that automatically detect the code we no longer need.
The speed of code generation should not translate into an equal speed of technical-debt accumulation.
The goal is not only to write more code.
It is to write, change, and delete code with the same confidence.
How to integrate it
The practical part can wait until the end: install, configure, and wire Knip into the project workflow.
Install and basic configuration
The most direct way to start, according to the official docs, is to initialize Knip with its config generator:
pnpm create @knip/config
# or: npm init @knip/configYou can also install it manually as a dev dependency and add a script:
pnpm add -D knip typescript @types/node{
"scripts": {
"knip": "knip"
}
}Configuration usually lives in knip.json, knip.config.ts, or equivalent fields. The essentials are graph entries (entry), project scope (project), and, when needed, targeted exceptions:
import type { KnipConfig } from 'knip';
const config: KnipConfig = {
entry: ['src/lib/i18n/request.ts'],
project: ['src/**/*.{js,ts,jsx,tsx}'],
ignoreDependencies: ['lint-staged', 'tailwindcss'],
ignoreIssues: {
'./src/components/ui/**': ['files', 'exports', 'types'],
},
};
export default config;ignoreIssues is useful when you still want to analyze a directory but silence specific finding types — for example, exports from a component library that are not all consumed yet. That is different from removing those files from the graph entirely.
After a first manual pass, turn Knip into an audit command (pnpm knip) and, once the report is stable, into a hook or CI condition. Knip exits with code 1 when it finds issues, so it works well as a gate.
Knip on Edge Functions with Deno and Supabase
Most Knip examples assume Node.js or Bun. On a Deno Edge Functions backend — like Supabase’s — the fit is less obvious. Knip still has no first-class Deno support; it shows up as a requested capability in the FAQ. Even so, a practical bridge works.
The pattern that worked for me on a Deno/Supabase backend rests on three pieces:
- Run Knip via npm from Deno, for example with a task like deno run -A npm:knip.
- Keep a minimal package.json only because Knip expects one. In a real Deno project that file is a decoy: it should not accumulate dependencies or scripts; deno.json remains the source of truth.
- Declare Edge Functions as graph entries, usually each function’s index.ts, and map shared aliases (@shared/*) in the Knip config.
A typical configuration looks like this:
{
"entry": ["supabase/functions/**/index.ts"],
"project": ["supabase/functions/**/*.ts"],
"include": ["files", "dependencies", "exports", "unresolved"],
"paths": {
"@shared/*": ["./supabase/functions/_shared/*"]
}
}And in deno.json, alongside the npm import map (npm:zod, npm:@supabase/supabase-js, and so on), the task is explicit:
{
"imports": {
"@shared/": "./supabase/functions/_shared/",
"npm:knip": "npm:knip"
},
"tasks": {
"knip": "deno run -A npm:knip"
}
}That lets you audit the Edge Functions layer with the same idea as always: start from real entries (**/index.ts), follow imports into _shared, and detect files, exports, or references nothing reaches anymore. It is not first-class Deno support, but it turns Knip into a rule in that environment too — including pre-push hooks or CI — without inventing a fake Node ecosystem around a Deno runtime.
One nuance: Knip analyzes the TypeScript module graph and unused exports well, but Deno npm: dependencies do not always behave like a conventional package.json. It helps to narrow the report (include) and review early false positives before hardening the rule.
There is also agent integration via MCP: the docs recommend the MCP Server so agents can use Knip and work with its configuration.
If it helps, I also keep Knip in Resources, alongside other tools I use day to day.