ngx-phantom — Dead Code Eliminator for Angular Monorepos
ngx-phantom
Large Angular monorepos accumulate dead code silently. A library exports 40 symbols; over time, half of them stop being used anywhere. Nobody deletes them because nobody knows they're dead. The barrel file grows, the public API becomes a lie, and new developers spend time reading exports that nothing actually consumes.
I wanted a tool that could answer one question with certainty: which symbols are exported from my libraries but imported by nobody?
So I built ngx-phantom.
What it does
ngx-phantom statically analyzes your entire Angular workspace and reports every publicly exported symbol that has zero consumers across all apps and libraries. It also ships an auto-prune mode that rewrites barrel files to remove the dead exports.
npm install -g ngx-phantom
# or run without installing
npx ngx-phantom analyzeThe tool works by reading your tsconfig.base.json (NX) or tsconfig.json — wherever compilerOptions.paths maps library names to entry files — and from there, it knows your entire library graph.
The three commands
analyze — find dead exports
ngx-phantom analyzengx-phantom ▸ Analyze
──────────────────────────────────────────────────
Discovering workspace... found 24 libraries
Analyzing...
Done in 0.23s
Found 12 dead export(s) across 5 libraries:
@myorg/sort-pipe (1 dead)
✗ SortPipe libs/sort-pipe/src/lib/sort.pipe.ts:1
@myorg/button (1 dead)
✗ ButtonType libs/button/src/lib/models/button-type.ts:3Every dead symbol is reported with its source file and line number. The --fail flag makes it exit with code 1, which is useful for CI:
ngx-phantom analyze --exclude-tests --failprune — remove dead exports from barrel files
Always preview first:
ngx-phantom prune --dry-runThen apply:
ngx-phantom pruneNamed exports (export { Foo } from './foo') are removed or trimmed safely. Multi-line exports are handled correctly. Wildcard exports (export * from './path') are intentionally left untouched — those require human review.
explain — understand why a symbol is flagged
Before you prune, use explain to verify a specific symbol:
ngx-phantom explain --lib @myorg/sort-pipe --symbol SortPipe @myorg/sort-pipe is imported by 4 file(s):
apps/my-app/src/app/app.module.ts
...
Symbols consumed from @myorg/sort-pipe (1):
SortPipeModule
✗ "SortPipe" is exported but NEVER imported by any consumer.
Similar symbols that ARE consumed: SortPipeModuleThis is especially useful for the NgModule wrapper pattern — where a library exports both a component/pipe class and its NgModule wrapper, but all consumers only import the module. ngx-phantom correctly flags the bare class as dead.
How it works
1. Workspace discovery — reads tsconfig.base.json and extracts compilerOptions.paths. Paths pointing to dist/ are automatically resolved to the source src/index.ts or src/public-api.ts sibling.
2. Export parsing — for each library entry point, recursively resolves all export forms:
export { Foo, Bar } from './path'export type { Foo } from './path'export * from './path'— recursively followedexport * as Namespace from './path'- Direct declarations (
export class Foo,export const foo, etc.)
3. Import scanning — walks every .ts file in the workspace (skipping node_modules, dist, .git, .angular, coverage) and records all import statements referencing known library paths:
- Named:
import { Foo } from '@myorg/ui' - Type:
import type { Foo } from '@myorg/ui' - Namespace:
import * as X from '@myorg/ui'— marks the entire lib as opaque, skipped from analysis - Dynamic:
import('@myorg/ui') - Subpath:
import { Foo } from '@myorg/ui/testing'— mapped to@myorg/ui
4. Dead export analysis — symbols exported but absent from any consumer's import set are reported as dead.
5. Pruning — rewrites barrel files in-place, handling single-line and multi-line export { ... } blocks.
The Go → Zig rewrite
ngx-phantom was originally written in Go. It worked well, but I was curious whether Zig could meaningfully improve things — particularly binary size and memory efficiency. So I rewrote it from scratch.
The rewrite took the opportunity to also restructure the codebase into a proper modular layout: each subsystem (workspace, parser, analyzer, reporter, pruner) lives in its own folder with separate types.zig, helpers.zig, and the main module file.
Benchmark results
Tested on a real Angular monorepo — ~150 libraries, ~4,000 TypeScript files:
| Metric | Go | Zig | Improvement |
|---|---|---|---|
| Binary size | 5.9 MB | 288 KB | 95% smaller |
| Wall-clock time | 271.9 ms | 226.3 ms | ~17% faster |
| CPU time (user) | 1,017 ms | 63.7 ms | 16× less CPU |
| Source lines | ~2,000 | 1,931 | comparable |
Why the binary is 95% smaller
Go embeds its runtime and garbage collector into every binary. There's no way around it — even a trivial Go program is several megabytes. Zig compiles to a single native executable with no embedded runtime. The 288 KB binary is the actual program — nothing more.
Why CPU usage dropped 16×
The Go version uses goroutines for parallel file scanning. Goroutines are lightweight, but the Go runtime still manages a scheduler, stack growth, and a garbage collector. The GC alone accounts for significant CPU cycles on workloads that allocate a lot of short-lived strings (which file parsing does constantly).
The Zig rewrite uses arena allocators throughout. Each parallel worker gets its own arena — memory is claimed from a contiguous buffer via bump allocation, and the entire arena is freed in one call at the end. There are no individual free() calls, no GC pauses, and no allocator lock contention between threads.
The result: 63.7ms of CPU time versus 1,017ms. The program is spending almost all of its time on I/O, which is what you want.
A subtle Zig 0.15 gotcha
During the rewrite I hit an arena aliasing bug specific to Zig 0.15's in-place realloc behavior. When an ArrayList backed by an arena grows, Zig may try to realloc in-place — and if you have two slices pointing into the same arena region, the second one becomes a dangling reference after the realloc.
The fix was to use page_allocator for buffers that needed to survive across potential arena reallocations, and to ensure per-worker arenas are strictly owned and never shared. Once I understood the memory model, the rules were straightforward — but it took a few Segmentation fault crashes to get there.
Common patterns explained
NgModule wrapper pattern
This is the most common false-positive concern. Many Angular libraries export both the directive/pipe class and an NgModule:
// index.ts
export { SortPipe } from './lib/sort.pipe';
export { SortPipeModule } from './lib/sort-pipe.module';If all consumers import SortPipeModule but nobody imports SortPipe directly, ngx-phantom flags SortPipe as dead. This is correct — the class is used at runtime via the module declaration, but it is not part of the public TypeScript API. If you're migrating to standalone components you can prune it alongside removing the module.
Namespace imports
Libraries consumed via import * as X from '@myorg/lib' cannot be analyzed statically — ngx-phantom skips them entirely and lists them under "Skipped libraries". If a library shows zero dead exports but you expected some, check whether something is importing it with a namespace import.
Why I built this
Dead exports are a slow-moving problem. In a monorepo with a dozen teams, nobody owns the barrel files. Symbols accumulate. The public API of a library starts to reflect what was once exported, not what is actually useful. Tree-shaking helps at the bundle level but it doesn't clean up your codebase or your cognitive load.
ngx-phantom gives you a clear picture of what your libraries actually expose to the outside world versus what they only export out of habit. Run it in CI with --fail and dead exports never pile up.
Check it out: ngx-phantom on npm