Your types are already a fuzzing spec
Classic fuzzing feeds random bytes into a parser and waits for a crash. That works well for C code and binary formats, but most TypeScript libraries do not take bytes. They take a string, a Date, a number[] and a callback. The interesting question is rarely "does it crash" and more often "does it return something wrong".
A type signature tells a fuzzer what shape to generate. Random strings of that shape are weak, though. A bug in a text function hides in an emoji or a lone surrogate, not in the 500th random ASCII word. Type driven fuzzing means generating values that are valid for the type and chosen to be nasty.
A typed function that is quietly wrong
Consider a small formatter. Its types are correct, it compiles with strict on, and its unit tests with 1.5, 100 and 0 all pass.
export function formatFixed(value: number): string {
const whole = Math.trunc(value);
const frac = Math.abs(value - whole).toFixed(2).slice(1);
return `${whole}${frac}`;
}
For -0.5, Math.trunc returns -0, and a template literal prints -0 as "0". The result is "0.50", the same as for 0.5. The sign is gone, so a refund of 50 cents shows up as a charge. This is one of the negative zero bugs that only appears for values between -1 and 0.
The fix decides the sign from the original value and formats the absolute value:
export function formatFixed(value: number): string {
const sign = value < 0 ? "-" : "";
return sign + Math.abs(value).toFixed(2);
}
How breakhound turns types into inputs
breakhound reads the declaration file for each exported function. For the function above it sees this:
export declare function formatFixed(value: number): string;
A number parameter gets 0, -0, NaN, Infinity, values between -1 and 0 and very large magnitudes, all from the edgegoat catalog. A string gets empty strings, emoji, combining marks and lone surrogates. A Date gets daylight saving boundaries and dates before 1970. If a parameter is a function, such as (word: string) => string, breakhound generates callbacks for it too, so higher order helpers like a mapWords are exercised.
Then it checks oracles on the results. These are not crash only checks: a function that returns NaN for finite input, changes its answer with the TZ environment variable or loses a minus sign is flagged even though it never throws.
Run it and read the output
npx breakhound ./my-lib --seed 42 --max-calls 20000
Both flags show their defaults here: seed 42 and a budget of 20000 calls. Every call runs in an isolated child process with a timeout (2000 ms by default), so a function that hangs or blows the stack does not take the run down. The output on the buggy-lib fixture includes this finding:
MEDIUM (5)
[negative-zero-sign] formatFixed drops the minus sign of numbers between -1 and 0 (confidence medium)
formatFixed(-0.5) -> '0.50'
formatFixed(0.5) -> '0.50'
formatFixed(-1.5) -> '-1.50'
INFO (2)
[unexpected-crash] percent throws RangeError for some inputs that match its types (confidence high)
Summary: 5 high, 5 medium, 0 low, 2 info
The generated test pins the behavior down so it keeps failing until you fix it:
import { expect, test } from 'vitest';
import { formatFixed } from 'buggy-lib';
test("formatFixed keeps the sign of -0.5", () => {
expect(formatFixed(-0.5)).not.toBe(formatFixed(0.5));
});
breakhound, fast-check and Jazzer.js
These tools solve different problems, and many projects benefit from more than one.
| Tool | What you write | Best at |
|---|---|---|
| fast-check | Properties and arbitraries | Domain specific invariants, with shrinking to a minimal counterexample |
| Jazzer.js | Fuzz targets | Crashes and security bugs in parsers, guided by code coverage |
| breakhound | Nothing | Generic invariant checks driven by types, across a whole package |
If your library has rules only you know (a sorted list stays sorted, a diff applied to the old text gives the new text), write those properties with fast-check. If it parses untrusted input, add a Jazzer.js target. Use breakhound first because it costs one command and covers the class of bugs shared by almost every library. The related guide on property based testing without writing tests goes further on that tradeoff.
Make the fuzz run repeatable
Fuzzing that cannot be reproduced is noise. breakhound is seeded, so a failure on a laptop is a failure in CI with the same --seed. It also keeps the evidence: a run is saved to .breakhound/last-run.jsonl and .breakhound/report.json, and --log writes a JSON Lines log you can attach to an issue.
npx breakhound . --fail-on high --zones UTC,America/Sao_Paulo --timeout 1000
In GitHub Actions it adds annotations and a job summary automatically, as shown in automated testing in GitHub Actions. Wiring a fuzz pass into pull requests is where it pays off most, since the cost of a regression is smallest on the commit that caused it.
breakhound runs the package code it analyzes. Use it on code you trust or in a container. The child process isolation protects the run from hangs and crashes, but it is not a security sandbox.
FAQ
Is this real fuzz testing for TypeScript?
It is type driven fuzzing: inputs are generated from your declared types plus a curated edge case catalog, and results are checked against invariants. It is not coverage guided, so it does not mutate bytes to reach new branches the way Jazzer.js does.
Do I need to write fuzz targets or arbitraries?
No. Run npx breakhound ./my-lib and it reads the .d.ts types, generates inputs for each parameter, including generated callbacks, and reports findings with a reproduction.
Can I restrict it to certain functions or rules?
Yes. Use --only and --exclude with wildcards for functions, and --rules to pick rule ids such as nan-leak or roundtrip.
What if a function hangs or runs forever?
Each call runs in a separate child process with a timeout, which you can change with --timeout. A hang is contained and does not stop the rest of the run.
Does it replace my unit tests?
No. It finds the inputs you did not think of, and every finding becomes a Vitest test you can keep next to your existing ones. Start with the guide to finding bugs automatically for the full workflow.