Knip: Finding Dead Code and Unused Dependencies in JavaScript Projects
Knip: Finding Dead Code and Unused Dependencies in JavaScript Projects
Every codebase accumulates dead code. A utility function written for a feature that got reverted. A dependency added for one component that nobody uses anymore. An exported type that no consumer imports. A configuration file for a tool the team stopped using two years ago. Individually, each piece of dead code is harmless. Collectively, they slow down builds, confuse new developers, inflate bundle sizes, and create a false sense of complexity.
Knip is a static analysis tool that finds all of it: unused files, unused exports, unused dependencies, unlisted dependencies, and unused configuration. It understands the JavaScript and TypeScript ecosystem deeply -- it knows how tools like Jest, Storybook, Vitest, ESLint, Webpack, and dozens of others reference files, so it doesn't flag your test setup file as "unused" just because nothing imports it directly.
What Knip Detects
Knip performs several categories of analysis in a single run:
- Unused files: Source files that nothing imports or references
- Unused exports: Functions, classes, types, and constants that are exported but never imported elsewhere
- Unused dependencies: Packages in
package.jsonthat no source file imports - Unlisted dependencies: Packages that source files import but that aren't in
package.json - Unused devDependencies: Dev packages that no tool or config references
- Duplicate dependencies: The same package listed in both
dependenciesanddevDependencies - Unused binaries: CLI tools installed via packages but never referenced in scripts
- Unresolved imports: Import statements that point to modules that don't exist
This breadth is what distinguishes Knip from simpler tools. ESLint can flag unused variables within a file, but it can't tell you that an entire file is unused. depcheck can find unused dependencies, but it doesn't understand unused exports. Knip does all of it in a single pass.
Quick Start
Installation
# bun
bun add -D knip
# npm
npm install -D knip
# pnpm
pnpm add -D knip
First Run
bunx knip
That's it. With zero configuration, Knip analyzes your project using sensible defaults: it finds your entry points (typically src/index.ts or whatever package.json main/exports point to), traces all imports, and reports everything that's unreachable.
Example Output
Unused files (3)
src/utils/deprecated-helpers.ts
src/components/OldNavbar.tsx
src/lib/legacy-api-client.ts
Unused dependencies (2)
moment
lodash
Unused devDependencies (1)
@types/lodash
Unused exports (5)
src/utils/format.ts: formatPhoneNumber
src/utils/format.ts: formatSSN
src/hooks/useDebounce.ts: useDebounce (type)
src/api/client.ts: ApiClient (class)
src/types/index.ts: LegacyUser (type)
Unlisted dependencies (1)
zod (imported in src/api/validation.ts but not in package.json)
Each finding is actionable. Unused files can be deleted. Unused dependencies can be removed from package.json. Unused exports can be unexported (or the function deleted if it's truly dead). Unlisted dependencies need to be added to package.json.
Configuration
Knip works without configuration for simple projects, but real codebases need to tell Knip about non-standard entry points and tool-specific files. Create a knip.json (or knip.ts for TypeScript) in your project root:
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"entry": [
"src/index.ts",
"src/server.ts",
"scripts/*.ts"
],
"project": ["src/**/*.{ts,tsx}"],
"ignore": [
"src/**/*.test.ts",
"src/**/*.stories.tsx"
],
"ignoreDependencies": [
"autoprefixer"
]
}
Entry Points
Entry points are the roots of your dependency graph. Knip traces imports starting from these files. Anything not reachable from an entry point is flagged as unused.
{
"entry": [
"src/index.ts",
"src/worker.ts",
"src/cli.ts",
"scripts/**/*.ts"
]
}
Knip automatically detects entry points for many tools (Next.js pages, Remix routes, Vitest test files, etc.), so you often only need to add custom entry points that aren't covered by plugin auto-detection.
Plugin System
Knip's real power is its plugin system. It has built-in plugins for 80+ tools that understand how each tool references files. This means Knip won't incorrectly flag your jest.setup.ts as unused -- it knows Jest references it through jest.config.ts.
Supported tools include:
| Category | Plugins |
|---|---|
| Frameworks | Next.js, Remix, Astro, Nuxt, SvelteKit, Gatsby |
| Testing | Vitest, Jest, Playwright, Cypress, Storybook |
| Bundlers | Webpack, Vite, Rspack, Rollup, esbuild |
| Linting | ESLint, Biome, Prettier, Stylelint, Commitlint |
| Build | TypeScript, Babel, SWC, PostCSS, Tailwind CSS |
| CI/CD | GitHub Actions, Husky, lint-staged |
| Other | Drizzle, Prisma, GraphQL Codegen, Sentry, Knex |
Plugins are enabled automatically when Knip detects the tool's configuration file in your project. You can explicitly configure them if the defaults aren't right:
{
"vitest": {
"config": ["vitest.config.ts"],
"entry": ["src/**/*.{test,spec}.ts"]
},
"storybook": {
"config": [".storybook/main.ts"],
"entry": ["src/**/*.stories.tsx"]
}
}
Monorepo Support
Knip handles monorepos natively. Define workspace-specific configuration:
{
"workspaces": {
"packages/core": {
"entry": ["src/index.ts"],
"ignoreDependencies": ["some-peer-dep"]
},
"packages/ui": {
"entry": ["src/index.ts", "src/components/*/index.ts"]
},
"apps/web": {
"entry": ["src/main.tsx"]
}
}
}
Knip traces cross-workspace imports correctly. If apps/web imports from packages/core, those exports are marked as used.
Comparison with Alternatives
| Feature | Knip | eslint-plugin-unused-imports | depcheck | ts-prune | unimported |
|---|---|---|---|---|---|
| Unused files | Yes | No | No | No | Yes |
| Unused exports | Yes | No | No | Yes | No |
| Unused dependencies | Yes | No | Yes | No | No |
| Unlisted dependencies | Yes | No | Yes | No | No |
| Unused devDependencies | Yes | No | Partial | No | No |
| Tool-aware (80+ plugins) | Yes | No | Limited | No | Limited |
| Monorepo support | Yes | N/A | No | No | No |
| Auto-fix | Partial | Yes (remove imports) | No | No | No |
| Performance (large project) | Fast | Fast | Slow | Moderate | Moderate |
| Configuration needed | Minimal | ESLint config | Minimal | tsconfig | Moderate |
Knip vs eslint-plugin-unused-imports
eslint-plugin-unused-imports is useful but solves a much narrower problem. It finds unused import statements within individual files and can auto-fix them (remove the import line). It doesn't know whether the imported module itself is unused by the rest of the project, whether the dependency is needed in package.json, or whether entire files are dead.
Think of it this way: eslint-plugin-unused-imports cleans up within files. Knip cleans up across your entire project. They're complementary -- use both.
Knip vs depcheck
depcheck specifically targets package.json accuracy: are all listed dependencies actually used? Are all used dependencies listed? It does this job reasonably well but has significant blind spots:
- It doesn't understand many tool configuration files (misses dependencies referenced only in config)
- It's slow on large projects (no incremental analysis)
- It doesn't detect unused files or unused exports
Knip replaces depcheck entirely and adds much more.
Knip vs ts-prune
ts-prune finds unused exports in TypeScript projects. It was useful when it was the only option, but Knip subsumes its functionality and adds unused file detection, dependency analysis, and plugin-aware scanning. ts-prune is also unmaintained as of 2024.
CI Integration
Running Knip in CI catches dead code before it merges. Add it to your pipeline:
{
"scripts": {
"knip": "knip",
"knip:ci": "knip --no-progress"
}
}
GitHub Actions
- name: Check for unused code
run: bunx knip --no-progress
Knip exits with a non-zero code when it finds issues, so your CI pipeline will fail on unused code. This is the single most effective way to prevent dead code accumulation.
Strict vs. Gradual Adoption
For existing projects with years of accumulated dead code, failing CI immediately isn't practical. Use Knip's --include flag to enable categories gradually:
# Start with just unused dependencies (easiest to fix)
bunx knip --include dependencies
# Add unlisted dependencies
bunx knip --include dependencies,unlisted
# Add unused files
bunx knip --include dependencies,unlisted,files
# Eventually, enable everything (the default)
bunx knip
This lets you clean up one category at a time, merge the cleanups, and then tighten the CI check.
Reporter Options
Knip supports multiple output formats:
# Default human-readable output
bunx knip
# JSON for programmatic consumption
bunx knip --reporter json
# Compact (one line per issue)
bunx knip --reporter compact
# Markdown (useful for PR comments)
bunx knip --reporter markdown
Practical Workflow
Here's how to use Knip effectively on an existing project:
Step 1: Baseline Run
bunx knip | tee knip-baseline.txt
Review the output. You'll likely see dozens or hundreds of findings on a mature codebase. Don't panic.
Step 2: Fix False Positives
Some findings will be incorrect -- files or exports that are used in ways Knip can't detect (dynamic imports, metaprogramming, external tool references). Update your configuration:
{
"ignore": [
"src/generated/**"
],
"ignoreDependencies": [
"autoprefixer",
"@babel/runtime"
],
"ignoreExportsUsedInFile": true
}
The ignoreExportsUsedInFile option is particularly useful: it tells Knip not to flag exports that are used within the same file (common for barrel files that re-export internal utilities).
Step 3: Clean Up Genuine Dead Code
Work through the remaining findings:
- Unused dependencies:
bun remove moment lodash(and install any replacements) - Unused files:
rm src/utils/deprecated-helpers.ts - Unused exports: Remove the
exportkeyword, or delete the function entirely if nothing uses it internally either
Step 4: Add to CI
Once the baseline is clean, add Knip to your CI pipeline so dead code can't creep back in.
Step 5: Run Periodically with --strict
The --strict flag enables additional checks beyond the default:
bunx knip --strict
This is more aggressive about flagging potential issues and is useful for periodic deep-cleaning, even if it's too noisy for CI.
Performance
Knip is fast because it leverages TypeScript's compiler API for import resolution rather than doing its own parsing. On a project with 2,000 TypeScript files:
- First run: ~8 seconds
- Subsequent runs (with TypeScript's incremental cache): ~3 seconds
This is fast enough to run on every CI build and as a pre-commit hook for smaller projects.
Common Pitfalls
Dynamic imports: import(variable) can't be statically analyzed. If you dynamically import modules, add those modules as entry points in Knip's configuration.
Barrel files: A file like src/utils/index.ts that re-exports everything from submodules might show unused exports if some re-exported items aren't consumed externally. Use ignoreExportsUsedInFile or restructure your barrel files.
Types-only packages: Some @types/* packages are used by TypeScript's type resolution but not explicitly imported. Knip usually detects these correctly through its TypeScript plugin, but occasionally you'll need to add them to ignoreDependencies.
Peer dependencies: Packages listed as peer dependencies may appear unused because the host package provides them. Configure ignoreDependencies for legitimate peer deps.
The Bottom Line
Dead code is a maintenance tax that compounds over time. Every unused file is a file that new developers read and try to understand. Every unused dependency is an attack surface and a bun install slowdown. Every unused export is a signal that the codebase might use something it actually doesn't.
Knip finds all of it with minimal configuration and integrates cleanly into CI pipelines. The initial cleanup of an existing codebase takes a few hours. After that, keeping the codebase clean is automatic. Of all the code quality tools available for JavaScript and TypeScript projects, Knip has one of the highest return-on-investment ratios: install it, run it, delete what it finds, and your codebase is immediately smaller, faster, and easier to understand.