deghost: The Unicode Sanitizer That Acts Like a String and a Builder

A tiny TypeScript library for invisible characters, whitespace normalization, and an unusually graceful API that gives beginners a one-liner and power users a chain.

8 min read • View on GitHub • More from kellymears

A desk scene shows one string of text splitting into two paths. On one path, hidden Unicode specks enter a search field and the output becomes corrupted. On the other, a small switch marked deghost sends the same text through a clean channel. The image explains that invisible characters can break downstream systems, and that the library turns cleanup into a deliberate API choice.
Invisible Unicode bugs are hard to see and easy to ship. `deghost` makes them explicit, then gives you a default cleanup path and a controlled chain when you need more precision.
Key Takeaways

Invisible Unicode bugs are the worst kind of production problem. They do not announce themselves, but they still break search, equality, rendering, and parsing. `deghost` exists to make that failure mode visible in the API itself.

A cleaner that behaves like a value

The hook is not that `deghost` cleans strings. Plenty of tools do that. The hook is that `deghost(input)` can feel like a string when you want the fast path, then open into a fluent builder when you need to control what gets stripped, normalized, or preserved.

That hybrid shape matters because cleanup APIs usually force a trade-off. You get either a tiny helper that hides the details or a builder that makes the common case feel heavy. `deghost` narrows that gap with a Proxy-backed return value and a default coercion path.

const cleaned = deghost(input)
  .strip()
  .normalize()
  .toString();

const easyPath = `${deghost(input)}`;

The bug class nobody sees

The project is aimed at ghost characters, the invisible codepoints that sneak into text streams. That includes BOMs, zero-width joiners, zero-width non-joiners, non-breaking spaces, and formatting marks that change behavior without changing appearance.

That matters because the damage is rarely cosmetic. A string can look identical in a UI and still fail a comparison, pollute an index, or confuse a parser. In other words, the bug is not in the text you see. It is in the text you do not.

The mental model is simple: detect the category, then route it to the right cleanup action. The library is precise because it is not trying to treat every invisible character the same way.

A close-up inspection bench shows a line of letters with tiny hidden marks between them. Small trays sort those marks into three destinations: strip, normalize, and preserve. A magnifying glass reveals the difference between a format character and a space character, explaining that the library classifies invisible codepoints before deciding how to treat them.
`deghost` is category-aware, not blunt. It makes cleanup intent explicit instead of collapsing every weird character into one bucket.

How `deghost` decides what to strip, normalize, or preserve

The interesting part of the implementation is not the cleanup itself. It is the decision system around cleanup. The repo organizes Unicode categories in one place, then uses those categories to build targeted regexes and downstream transforms.

That gives the library a disciplined core. Formatting marks can be treated differently from spaces. Whitespace can be normalized without flattening everything into nothing. The result is closer to editorial judgment than brute-force filtering.

ApproachInvisible charactersWhitespaceIntent awarenessDependencies
Naive trim and replace chainsMisses many casesUsually only surface-levelLowNone
Generic regex strippingCatches more, but often overremovesOften removed blindlyLow to mediumNone
deghostCategory-aware handlingNormalized when appropriateHighNone

Inside the scanner

The scanner is built for selective attention. `scan()` is a generator, so callers can stop as soon as they find what they care about. `first()` turns that into a fast path for cases where the only question is whether a ghost character exists at all.

That design keeps the code small without making it naive. `categories.ts` holds the metadata, `detect.ts` turns that metadata into cached regexes, and the scanner streams detections instead of forcing a full pass when the caller does not need one.

for (const detection of scan(input)) {
  console.log(detection.category, detection.index);
  break;
}

const firstGhost = first(input);

Why the chain stays immutable

`DeghostChain` keeps each method call pure by returning a new chain every time. That is the right trade-off for a text-cleaning pipeline, because it makes reuse and composition predictable.

The companion `CleanerBuilder` pushes that idea further. If you are cleaning the same kind of input repeatedly, you can compile a repeatable pipeline once and run it many times without rethinking the whole rule set on every call.

Design choiceWhy it helps
Immutable chainEach transformation stays predictable and reusable
Compiled cleanerRepeated cleanup gets cheaper and easier to apply
Proxy entry pointThe simple case stays simple without dropping power

What it gets right about modern JavaScript

The library leans on Unicode property escapes, which is exactly the right move. Native `RegExp` support is enough to recognize modern character classes without shipping a huge manual table, and that keeps the package dependency-free.

That is not just elegant. It is leverage. The code gets to stay small because the runtime already knows a lot about Unicode, and the API gets to expose that knowledge without hiding it behind an overloaded abstraction.

The blunt alternatives

Against `trim()` chains, the repo wins on precision. Against hand-rolled regex cleanup, it wins on structure. Against larger Unicode libraries, it wins on surface area and on the clarity of its intent.

OptionStrengthWeakness
trim and replaceQuick to writeHard to maintain, easy to overreach
Ad hoc regexFlexibleBrittle and opaque
Large Unicode tablesBroad coverageHeavier than the problem usually needs
deghostPrecise and smallRequires you to think in categories

Why this repo punches above its size

The maturity signal here is architectural taste. Strict TypeScript, tests, modern bundling, zero dependencies, and a focused README all point in the same direction. This is a small repository that behaves like a carefully designed library, not a weekend script.

That is why the project is interesting. It uses modern language features to solve a narrow but real problem, and it does so with an API shape that respects both casual users and power users.