ts-fuzzing
v0.1.2
Published
TypeScript-driven value fuzzing and quick-checking with optional UI adapters
Downloads
38
Maintainers
Readme
ts-fuzzing
Generate values from TypeScript types or schemas, then run quick-checks or fuzzing against arbitrary callbacks and UI components.
API quick reference
| Workflow | Helpers |
| --- | --- |
| Generate values | sampleValues, sampleBoundaryValues, sampleProps, sampleBoundaryProps, sampleValuesFromSchema, sampleBoundaryValuesFromSchema |
| Property-based fuzzing | fuzzValues, quickCheckValues, fuzzValuesGuided, fuzzValuesMulti, replayValues, replayFromError |
| Component fuzzing | fuzzComponent, quickCheckComponent, fuzzComponentGuided, fuzzReactComponent (ts-fuzzing/react), createVueDomRender (ts-fuzzing/vue), createSvelteRender (ts-fuzzing/svelte) |
| Invariant helpers | fuzzRoundtrip, fuzzIdempotent, fuzzCommutative, fuzzAssociative, fuzzMonotonic |
| Stateful / sequence | fuzzStateful, StatefulFuzzError |
| Differential | fuzzDifferential |
| Corpus / regression | loadCorpus, saveCorpus, appendToCorpus, fuzzFromCorpus, fuzzFromCorpusWithMutation |
| Mutation | mutateValue, generateMutations |
| Statistics | collectStatistics, formatStatistics, formatGuidedReport |
| Failure tooling | ValueFuzzError, ComponentFuzzError, renderReproTest, writeReproTest |
| Low-level | analyzeTypeDescriptor, analyzePropsDescriptor, arbitraryFromDescriptor, boundaryValuesFromDescriptor, schemaSupportFromSchema |
| Marker types | UUID, ULID, ISODateString, Int, Float, Min, Max, MinLength, MaxLength, MinItems, MaxItems, Pattern<...> |
Install
Requirements:
- Node.js
24+ - ESM (
"type": "module")
pnpm add -D ts-fuzzing vitest
# Optional schema support
pnpm add -D zod valibot
# Optional React adapter
pnpm add react react-dom
# Optional framework adapters
pnpm add vue
pnpm add svelteQuick Start
Use exported TypeScript types as the source of truth, then run a generic consumer callback with generated values.
The examples below assume you are writing vitest test cases.
import { expect, test } from "vitest";
import { fuzzValues } from "ts-fuzzing";
type SearchQuery = {
page?: number;
term: string;
};
test("fuzzes a callback with values generated from a TypeScript type", async () => {
await expect(
fuzzValues<SearchQuery>({
sourcePath: new URL("./search.ts", import.meta.url),
typeName: "SearchQuery",
numRuns: 200,
seed: 42,
run(value) {
executeSearch(value);
},
}),
).resolves.toBeUndefined();
});Failures throw ValueFuzzError.
import { expect, test } from "vitest";
import { ValueFuzzError, fuzzValues } from "ts-fuzzing";
test("inspects the minimized failing value", async () => {
expect.assertions(4);
try {
await fuzzValues<SearchQuery>({
sourcePath: new URL("./search.ts", import.meta.url),
typeName: "SearchQuery",
seed: 42,
run(value) {
executeSearch(value);
},
});
} catch (error) {
expect(error).toBeInstanceOf(ValueFuzzError);
if (error instanceof ValueFuzzError) {
expect(error.failingValue).toBeDefined();
expect(error.seed).toBe(42);
expect(error.warnings).toBeInstanceOf(Array);
}
}
});Common Usage
Sample generated values
sampleValues* returns an async iterator. Consume it with for await...of when you want a snapshot in a test. Reuse the same seed to replay the same yielded sequence.
import { expect, test } from "vitest";
import { sampleValues } from "ts-fuzzing";
type SearchQuery = {
page?: number;
term: string;
};
test("samples generated values for snapshot-like inspection", async () => {
const values: SearchQuery[] = [];
for await (const value of sampleValues<SearchQuery>({
sourcePath: new URL("./search.ts", import.meta.url),
typeName: "SearchQuery",
numRuns: 8,
seed: 1,
})) {
values.push(value);
}
expect(values).toHaveLength(8);
expect(values[0]).toHaveProperty("term");
});Boundary-focused checks
import { expect, test } from "vitest";
import { quickCheckValues } from "ts-fuzzing";
test("checks boundary-focused cases", async () => {
const report = await quickCheckValues<SearchQuery>({
sourcePath: new URL("./search.ts", import.meta.url),
typeName: "SearchQuery",
maxCases: 64,
run(value) {
executeSearch(value);
},
});
expect(report.checkedCases).toBeGreaterThan(0);
expect(report.totalCases).toBeGreaterThan(0);
});Type-level fuzzing hints
Keep the original base type visible and add fuzzing-specific hints through intersections.
import type {
ISODateString,
Int,
Max,
MaxLength,
Min,
MinLength,
Pattern,
UUID,
} from "ts-fuzzing";
export type SearchQuery = {
id: string & UUID;
createdAt: string & ISODateString;
page: number & Int & Min<1> & Max<10>;
term: string & MinLength<1> & MaxLength<32>;
contact: string & Pattern<"email">;
};These hints affect fuzzing only. They do not validate runtime values by themselves.
Schema-driven values
import { expect, test } from "vitest";
import * as z from "zod";
import { fuzzValues, sampleValuesFromSchema } from "ts-fuzzing";
const querySchema = z.object({
term: z.string().min(1).max(32),
page: z.number().int().min(1).max(10),
});
test("generates normalized values directly from schema", async () => {
const values: Array<z.infer<typeof querySchema>> = [];
for await (const value of sampleValuesFromSchema({
schema: querySchema,
numRuns: 16,
seed: 1,
})) {
values.push(value);
}
expect(values).toHaveLength(16);
});
test("fuzzes a callback from schema output", async () => {
await expect(
fuzzValues({
schema: querySchema,
numRuns: 100,
seed: 1,
run(value) {
executeSearch(value);
},
}),
).resolves.toBeUndefined();
});React components
Use the React adapter when the consumer is a component rather than a plain callback.
import { expect, test } from "vitest";
import { fuzzReactComponent } from "ts-fuzzing/react";
import { Button } from "./Button.js";
test("fuzzes a React component from its exported props type", async () => {
await expect(
fuzzReactComponent({
component: Button,
sourcePath: new URL("./Button.tsx", import.meta.url),
exportName: "Button",
numRuns: 200,
seed: 42,
}),
).resolves.toBeUndefined();
});By default React uses renderToStaticMarkup. Use createReactDomRender() if you need mount-time failures such as useEffect crashes.
import { expect, test } from "vitest";
import { createReactDomRender, fuzzReactComponent } from "ts-fuzzing/react";
test("catches mount-time React failures with the DOM renderer", async () => {
await expect(
fuzzReactComponent({
component: EffectBomb,
sourcePath: new URL("./EffectBomb.tsx", import.meta.url),
exportName: "EffectBomb",
render: createReactDomRender(),
numRuns: 100,
seed: 1,
}),
).rejects.toThrow();
});Provider fuzzing
import { expect, test } from "vitest";
import { createReactDomRender, quickCheckReactComponent } from "ts-fuzzing/react";
test("quick-checks provider and component inputs together", async () => {
const report = await quickCheckReactComponent({
component: ThemePanel,
sourcePath: new URL("./ThemePanel.tsx", import.meta.url),
exportName: "ThemePanel",
render: createReactDomRender({
providers: [
{
key: "themeProvider",
component: ThemeProvider,
sourcePath: new URL("./ThemeProvider.tsx", import.meta.url),
exportName: "ThemeProvider",
fixedProps: { locale: "en-US" },
},
],
}),
});
expect(report.checkedCases).toBeGreaterThan(0);
});Vue and Svelte
Use fuzzComponent() with a framework-specific renderer.
import { expect, test } from "vitest";
import Widget from "./Widget.vue";
import { fuzzComponent } from "ts-fuzzing";
import { createVueDomRender } from "ts-fuzzing/vue";
test("fuzzes a Vue component through the generic component API", async () => {
await expect(
fuzzComponent({
component: Widget,
sourcePath: new URL("./Widget.vue", import.meta.url),
render: createVueDomRender(),
numRuns: 100,
seed: 1,
}),
).resolves.toBeUndefined();
});import { expect, test } from "vitest";
import Widget from "./Widget.svelte";
import { quickCheckComponent } from "ts-fuzzing";
import { createSvelteRender } from "ts-fuzzing/svelte";
test("quick-checks a Svelte component through the generic component API", async () => {
const report = await quickCheckComponent({
component: Widget,
sourcePath: new URL("./Widget.svelte", import.meta.url),
render: createSvelteRender(),
maxCases: 32,
});
expect(report.checkedCases).toBeGreaterThan(0);
});Invariant helpers
fuzzRoundtrip, fuzzIdempotent, fuzzCommutative, fuzzAssociative, and fuzzMonotonic each skip the fast-check ceremony for a common property shape. The commutative/associative/monotonic variants internally draw two or three independent samples from the same descriptor or schema, so you still supply only the single-value source.
import { expect, test } from "vitest";
import * as z from "zod";
import { fuzzIdempotent, fuzzRoundtrip } from "ts-fuzzing";
const record = z.object({ name: z.string().min(1).max(16) });
test("JSON encode/decode roundtrips", async () => {
await expect(
fuzzRoundtrip({
schema: record,
numRuns: 100,
encode(value) { return JSON.stringify(value); },
decode(text) { return JSON.parse(text); },
}),
).resolves.toBeUndefined();
});
test("trim is idempotent", async () => {
await expect(
fuzzIdempotent({
schema: z.object({ text: z.string().max(8) }),
apply(value) { return { text: value.text.trim() }; },
}),
).resolves.toBeUndefined();
});
test("addition is commutative and associative", async () => {
const pair = z.object({ n: z.number().int().min(-8).max(8) });
await fuzzCommutative({ schema: pair, operation: (a, b) => a.n + b.n });
await fuzzAssociative({ schema: pair, operation: (a, b) => ({ n: a.n + b.n }) });
});
test("doubling is monotonic", async () => {
await fuzzMonotonic({
schema: z.object({ n: z.number().int().min(-8).max(8) }),
compareInput: (a, b) => a.n - b.n,
mapping: ({ n }) => n * 2,
});
});Differential testing
fuzzDifferential runs two implementations against the same generated input and reports the first divergence.
import { fuzzDifferential } from "ts-fuzzing";
await fuzzDifferential({
schema: inputSchema,
implementations: [legacyImpl, rewriteImpl],
names: ["legacy", "rewrite"],
numRuns: 200,
});Stateful / command sequence fuzzing
fuzzStateful generates a random sequence of actions, runs each against a reference model and the real implementation, and checks an invariant after every step.
import fc from "fast-check";
import { fuzzStateful } from "ts-fuzzing";
await fuzzStateful<{ items: number[] }, RealStack>({
setup: () => ({ model: { items: [] }, real: new RealStack() }),
actions: [
{
name: "push",
generate: fc.integer(),
apply({ model, real, input }) {
model.items.push(input);
real.push(input);
},
},
{
name: "pop",
precondition: (model) => model.items.length > 0,
apply({ model, real }) {
model.items.pop();
real.pop();
},
},
],
invariant({ model, real }) {
if (model.items.length !== real.size()) {
throw new Error("length diverged");
}
},
maxActions: 40,
numRuns: 100,
});Failures surface as StatefulFuzzError with a failingTrace describing the applied actions.
Seed replay
replayValues walks every iteration of a past fuzz run for a given seed, records whether each input passed or failed, and returns the full trace without stopping on the first failure. replayFromError takes a caught ValueFuzzError and rebuilds the run using its recorded seed.
import { ValueFuzzError, fuzzValues, replayFromError } from "ts-fuzzing";
try {
await fuzzValues({ sourcePath, typeName: "SearchQuery", run: executeSearch });
} catch (error) {
if (error instanceof ValueFuzzError) {
const report = await replayFromError({
error,
sourcePath,
typeName: "SearchQuery",
run: executeSearch,
});
for (const step of report.failures) {
console.log(step.iteration, step.value, step.cause);
}
}
throw error;
}onIteration lets you stream each step (e.g. to log every input that was attempted) without collecting the full trace in memory. Pass stopOnFirstFailure: true when you only want the first divergence but still need the preceding iterations.
Multi-failure collection
fuzzValues stops at the first failure so it can minimize the counterexample. When you want to find every distinct failing value in one pass (for example in a bug-sweep session), use fuzzValuesMulti. It generates samples with the same descriptor / schema pipeline, runs each input independently, deduplicates failures by serialized value, and returns a report.
import { fuzzValuesMulti } from "ts-fuzzing";
const report = await fuzzValuesMulti({
schema,
maxFailures: 5,
numRuns: 500,
run: executeSearch,
});
for (const failure of report.failures) {
console.error(failure.iteration, failure.value, failure.cause);
}report.failures lists up to maxFailures distinct failing inputs. Shrinking is intentionally skipped — run a single value through fuzzValues (or writeReproTest) when you want a minimized counterexample.
Statistics / coverage reporter
collectStatistics samples inputs through the same descriptor / schema pipeline and bucketizes each one through a user-supplied classify. It returns a sorted StatisticsReport you can assert on, and formatStatistics renders it as a human-readable histogram.
import { collectStatistics, formatStatistics } from "ts-fuzzing";
const report = await collectStatistics({
schema: querySchema,
numRuns: 500,
classify(value) {
if (value.page === 1) return "first-page";
if (value.page === 5) return "last-page";
return "middle";
},
});
console.log(formatStatistics(report));
// 60.5% [302/500] middle
// 19.8% [ 99/500] first-page
// 19.8% [ 99/500] last-pageReturn an array from classify to attach multiple labels per input (e.g. ["even", "boundary"]) and undefined to skip the input from the histogram.
formatGuidedReport(report) produces the equivalent text summary for the GuidedCoverageReport returned by fuzzValuesGuided / fuzzComponentGuided: total iterations, corpus size, every discovery with its iteration, reason, and the input that surfaced it.
Mutation
mutateValue and generateMutations apply constraint-aware tweaks (increment numbers by ±1/±2, edit a string character, push/pop an array entry, swap a nested object property) while keeping the value within the source or schema descriptor. Literals and enum branches are deliberately preserved so the value still matches its declared type. fuzzFromCorpusWithMutation expands every saved corpus entry into mutationsPerEntry neighbours and runs each through a check:
import {
fuzzFromCorpusWithMutation,
generateMutations,
mutateValue,
} from "ts-fuzzing";
const nearby = generateMutations({
schema,
count: 10,
seed: 1,
value: savedFailure,
});
const report = await fuzzFromCorpusWithMutation({
corpusPath,
schema,
mutationsPerEntry: 16,
collectAllFailures: true,
run: executeSearch,
});The report has the same shape as fuzzFromCorpus plus attempts (total mutations tried) and, on each failure, both the origin corpus entry and the mutation that actually broke the check.
Regression corpus
appendToCorpus() stores a failing value from ValueFuzzError.failingValue, and fuzzFromCorpus() re-runs every stored entry on the next test. Map and Set values are preserved across save/load.
import {
ValueFuzzError,
appendToCorpus,
fuzzFromCorpus,
fuzzValues,
} from "ts-fuzzing";
const corpusPath = new URL("./search-regression.json", import.meta.url);
try {
await fuzzValues({ schema, run: executeSearch });
} catch (error) {
if (error instanceof ValueFuzzError) {
appendToCorpus({ corpusPath, value: error.failingValue });
}
throw error;
}
// later, in the regression suite:
const report = await fuzzFromCorpus({
corpusPath,
collectAllFailures: true,
run: executeSearch,
});
expect(report.failures).toEqual([]);Minimal repro export
When a run fails, renderReproTest / writeReproTest turn the caught ValueFuzzError into a standalone test file so you can commit the failing input as a regression.
import { ValueFuzzError, fuzzValues, writeReproTest } from "ts-fuzzing";
try {
await fuzzValues({ schema, run: executeSearch });
} catch (error) {
if (error instanceof ValueFuzzError) {
writeReproTest({
error,
outputPath: new URL("./search.repro.test.ts", import.meta.url),
runnerImport: "./search.js",
runnerSymbol: "executeSearch",
});
}
throw error;
}Progress hooks
Every long-running runner (fuzzValues, quickCheckValues, fuzzValuesMulti, fuzzFromCorpus, fuzzFromCorpusWithMutation, plus their component-level wrappers) accepts an onProgress callback throttled by progressIntervalMs (default 1000). The hook receives { iteration, elapsedMs, failures, totalRuns? } so you can stream a status line during a long fuzz run without polluting fast paths with per-iteration logging.
await fuzzValues({
schema,
numRuns: 10_000,
progressIntervalMs: 2_000,
onProgress({ iteration, totalRuns, failures, elapsedMs }) {
console.log(`[${elapsedMs}ms] ${iteration}/${totalRuns} (${failures} failures)`);
},
run: executeSearch,
});The hook always fires once on completion (or before throwing) so the final iteration / failure count is reported even when the configured interval would otherwise suppress it. Use progressIntervalMs: 0 to receive an event after every iteration.
Time budget
All value- and component-based fuzzers accept timeoutMs (wall-clock interrupt, not marked as a failure) and perRunTimeoutMs (per-iteration hard timeout) on top of numRuns.
await fuzzValues({
schema,
numRuns: 10_000,
timeoutMs: 30_000, // stop after 30 seconds even if runs remain
perRunTimeoutMs: 500, // kill a single iteration that hangs past 500 ms
run: executeSearch,
});Notes
sampleProps*remains available as a component-focused alias. React adapter exports live underts-fuzzing/react, and Vue/Svelte render helpers live underts-fuzzing/vueandts-fuzzing/svelte.- source-based APIs cannot infer compile-time TypeScript types from
sourcePathalone. Use explicit generics such assampleValues<SearchQuery>(...)orfuzzValues<SearchQuery>(...)when you want editor typing. zodandvalibotcan describe values directly. GenericStandard Schemasupport is treated as a validator overlay unless the vendor adapter exposes a descriptor.- unresolved generic parameters use their
extendsconstraint when one can be generalized. If a generic cannot be generalized,ts-fuzzingemits a runtime warning and falls back tounknownvalue generation. - conditional types are supported when TypeScript already resolves them to concrete types or unions, and for simple unresolved conditionals that can be generalized from an
extendsconstraint. Conditional types that rely oninferstill fall back tounknownwith a warning. - marker types such as
UUID,ULID,ISODateString,Int,Float,Min,Max,MinLength,MaxLength,MinItems,MaxItems, andPattern<...>are intended to be intersected with an explicit base type such asstring & UUIDornumber & Int. - common external runtime types such as
ReactNode,URL,Map, andSetare normalized into dedicated fuzzing descriptors.
Examples and Docs
- Runnable non-UI example: examples/simple/README.md
- Runnable React adapter example: examples/react-first/README.md
- Runnable Vue adapter example: examples/vue-first/README.md
- Runnable Svelte adapter example: examples/svelte-first/README.md
- Design notes and implementation details: docs/architecture.md
