March 14, 2026 · 7 min read

ngx-phantom — Dead Code Eliminator for Angular Monorepos

#angular#zig#open-source#tooling#monorepo

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 analyze

The 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 analyze
ngx-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:3

Every 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 --fail

prune — remove dead exports from barrel files

Always preview first:

ngx-phantom prune --dry-run

Then apply:

ngx-phantom prune

Named 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: SortPipeModule

This 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 followed
  • export * 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