TypeScript fuzz/property testing utilities with test runner integrations
npm install typefuzz

TypeScript-first fuzz/property testing utilities with test runner integrations.
``ts
import { fuzz, gen } from 'typefuzz';
fuzz.assert(gen.array(gen.int(0, 10), 5), (values) => {
const doubleReversed = [...values].reverse().reverse();
return JSON.stringify(doubleReversed) === JSON.stringify(values);
}, { runs: 100, seed: 123 });
`
`sh`
bun add typefuzz
This repo uses Bun for all commands.
`sh`
bun install
bun run test
- gen.int(min, max) inclusive integer generatorgen.float(min, max)
- float generator in [min, max)gen.bigint(min, max)
- inclusive bigint generator (defaults 0n–100n)gen.bool()
- boolean generatorgen.string(lengthOrOptions)
- string from a character setgen.uuid()
- UUID v4 stringgen.email()
- basic email addressgen.date(min, max)
- date generator within boundsgen.array(item, length)
- fixed-length arraysgen.array(item, { minLength, maxLength })
- variable-length arraysgen.uniqueArray(item, { minLength, maxLength })
- unique arraysgen.object(shape)
- object from generator mapgen.record(value, { minKeys, maxKeys })
- record with string keysgen.dictionary(key, value, { minKeys, maxKeys })
- dictionary with custom keysgen.set(value, { minSize, maxSize })
- set generatorgen.oneOf(...options)
- random choicegen.weightedOneOf(options)
- weighted choicegen.frequency(options)
- alias for weightedOneOfgen.tuple(...items)
- heterogeneous tuplegen.optional(item, probability)
- optional valuesgen.constant(value)
- constant generatorgen.constantFrom(...values)
- constant choicegen.map(item, mapper, unmap?)
- map valuesgen.filter(item, predicate, maxAttempts?)
- filter values
The default character set is lowercase alphanumeric. Use an options object to pick a different set:
`ts`
gen.string(8); // 'alphanumeric' (default)
gen.string({ length: 16, charset: 'hex' }); // 0-9a-f
gen.string({ length: 6, charset: 'alpha' }); // a-z
gen.string({ length: 4, charset: 'numeric' }); // 0-9
gen.string({ length: 10, charset: 'ascii' }); // printable ASCII
gen.string({ length: 8, chars: 'ABC123' }); // custom character pool
Predefined charsets: 'alphanumeric', 'alpha', 'hex', 'numeric', 'ascii'.
- fuzz.assert(arbitrary, predicate, config?) run a property and throw on failurefuzz.property(arbitrary, predicate, config?)
- run and return a resultfuzz.replay(arbitrary, predicate, { seed, runs })
- replay a propertyfuzz.assertReplay(arbitrary, predicate, { seed, runs })
- replay and throw on failurefuzz.samples(arbitrary, count, config?)
- generate N values from an arbitraryfuzz.serializeFailure(failure)
- JSON-friendly failure payloadfuzz.formatSerializedFailure(payload)
- human-readable failure string
All core methods have async variants that accept async predicates:
- fuzz.assertAsync(arbitrary, predicate, config?)fuzz.propertyAsync(arbitrary, predicate, config?)
- fuzz.replayAsync(arbitrary, predicate, { seed, runs })
- fuzz.assertReplayAsync(arbitrary, predicate, { seed, runs })
-
`ts`
await fuzz.assertAsync(gen.int(1, 100), async (n) => {
const result = await someAsyncCheck(n);
return result.ok;
}, { runs: 50 });
`ts
import { fuzzIt } from 'typefuzz/vitest';
import { gen } from 'typefuzz';
fuzzIt('sum is commutative', gen.tuple(gen.int(0, 10), gen.int(0, 10)), ([left, right]) => {
return left + right === right + left;
}, { runs: 200, seed: 123 });
`
`ts
import { fuzzItAsync } from 'typefuzz/vitest';
import { gen } from 'typefuzz';
fuzzItAsync('async property', gen.int(1, 100), async (n) => {
const result = await fetchSomething(n);
return result.status === 200;
}, { runs: 50 });
`
Generate N values and register each as a separate test case, similar to it.each:
`ts
import { fuzzIt } from 'typefuzz/vitest';
import { gen } from 'typefuzz';
fuzzIt.each(gen.int(1, 100), 10, { seed: 42 })('is positive: %s', (n) => {
expect(n).toBeGreaterThan(0);
});
`
%s in the test name is replaced with a compact JSON representation of the value. An async variant is also available:
`ts`
fuzzIt.eachAsync(gen.string(8), 5)('validates: %s', async (s) => {
const result = await validate(s);
expect(result.ok).toBe(true);
});
No shrinking is performed — each case is a standalone test.
`ts
import { fuzzIt } from 'typefuzz/jest';
import { gen } from 'typefuzz';
fuzzIt('reverse is involutive', gen.array(gen.int(0, 10), 5), (values) => {
const doubleReversed = [...values].reverse().reverse();
return JSON.stringify(doubleReversed) === JSON.stringify(values);
}, { runs: 200, seed: 123 });
`
The .each and .eachAsync methods are available on the Jest adapter too.
Model-based testing verifies a stateful system against a simplified model. Typefuzz generates random command sequences, executes them against both the system and the model, and checks that the system matches the model after each step.
`ts
import { fuzz, gen } from 'typefuzz';
class Counter {
value = 0;
add(n: number) { this.value += n; }
reset() { this.value = 0; }
}
const result = fuzz.model({
state: () => ({ count: 0 }),
setup: () => new Counter(),
commands: [
{
name: 'increment',
arbitrary: gen.int(1, 5),
run: (counter, model, n) => { counter.add(n); model.count += n; },
check: (counter, model) => counter.value === model.count
},
{
name: 'reset',
run: (counter, model) => { counter.reset(); model.count = 0; },
check: (counter, model) => counter.value === 0,
precondition: (model) => model.count > 0
}
]
}, { runs: 100, maxCommands: 20 });
`
Each iteration creates a fresh model (via state()) and a fresh system (via setup()), then runs a random sequence of commands. For each step:
1. Filter commands by precondition (if defined)arbitrary
2. Pick a random eligible command
3. Generate a parameter (if the command has an )run(system, model, param)
4. Call to apply side effectscheck(system, model, param)
5. Call — if it returns false or throws, the sequence fails
On failure, the sequence is shrunk using delta-debugging chunk removal (tries removing contiguous chunks of decreasing size) followed by element-wise parameter shrinking, looping until convergence.
A command has:
- name — used in failure outputarbitrary?
- — generator for the command's parameter (omit for parameterless commands)precondition?
- — guard; command is only eligible when this returns truerun(system, model, param)
- — apply the operation to both system and modelcheck(system, model, param)
- — return false to signal failure, or use expect-style assertions (throw on mismatch). Returning true or void counts as passing.
- runs — number of iterations (default 100)maxCommands
- — max commands per sequence (default 20)maxShrinks
- — shrink budget (default 1000)seed
- — RNG seed for reproducibility
Provide an optional teardown(system) to clean up after each iteration. Called in a finally block so it runs even when the sequence fails or during shrink replays.
``
model-based test failed after 37/100 runs
seed: 42
shrinks: 15
command sequence:
1. increment(1)
2. buggyReset <-- check failed
replay: fuzz.model(spec, { seed: 42, runs: 100 })
- fuzz.model(spec, config?) — run and return ModelResultfuzz.modelAsync(spec, config?)
- — async variantfuzz.assertModel(spec, config?)
- — run and throw on failurefuzz.assertModelAsync(spec, config?)
- — async variantfuzz.serializeModelFailure(failure)
- — JSON-friendly failure payload
`ts
import { fuzzIt } from 'typefuzz/vitest';
fuzzIt.model('counter behaves correctly', {
state: () => ({ count: 0 }),
setup: () => new Counter(),
commands: [/ ... /]
}, { runs: 100 });
// Async variant
fuzzIt.modelAsync('async counter', { / ... / });
`
`ts
import { z } from 'zod';
import { zodArbitrary } from 'typefuzz/zod';
const schema = z.object({
name: z.string().min(2).max(5),
count: z.number().int().min(1).max(3)
});
const arb = zodArbitrary(schema);
`
- z.stringz.number
- (incl. int)z.boolean
- z.array
- z.object
- z.record
- z.tuple
- z.union
- z.discriminatedUnion
- z.literal
- z.enum
- z.nativeEnum
- z.optional
- z.nullable
- z.map
- z.set
- z.bigint
- z.date
- z.lazy
- z.default
- z.any
- / z.unknownz.effects
- (transforms, refinements, preprocess)z.undefined
- / z.void
When a property fails, typefuzz attempts to shrink the counterexample by reducing sizes (arrays, records, sets) and moving numbers/dates toward smaller values. The final counterexample is the smallest failing case found within the shrink budget.
For model-based tests, shrinking uses delta-debugging chunk removal to find the shortest failing command sequence, then shrinks individual parameter values. Both phases loop until no further improvement is found.
`ts
import { fuzz, gen } from 'typefuzz';
const arbitrary = gen.array(gen.int(0, 10), 5);
const predicate = (values: number[]) => values.length === 0;
// Replay a known failing seed
fuzz.assertReplay(arbitrary, predicate, { seed: 123, runs: 100 });
`
`ts
import { fuzz, gen } from 'typefuzz';
const result = fuzz.property(gen.int(1, 10), () => false, { seed: 42, runs: 1 });
if (!result.ok && result.failure) {
const serialized = fuzz.serializeFailure(result.failure);
console.log(fuzz.formatSerializedFailure(serialized));
}
`
- src/index.ts core APIsrc/vitest.ts
- Vitest adaptersrc/jest.ts
- Jest adaptersrc/generators.ts
- built-in generatorssrc/model.ts
- model-based testingsrc/zod.ts
- Zod schema adapter
Why do some generators throw?
Generators that require bounds (like gen.int or gen.date) validate inputs eagerly to surface errors early.
How deterministic are failures?
Failures include a seed and run count. Use fuzz.assertReplay to reproduce the same counterexample path.
Do I need Zod?
No. The Zod adapter is optional; core generators and fuzz helpers do not depend on it.
- Inclusive bounds: gen.int(min, max), gen.bigint(min, max), and gen.date(min, max) include both ends.gen.float(min, max)
- Half-open ranges: generates values in [min, max).gen.array(item, length)
- Fixed or variable arrays: for fixed-length, gen.array(item, { minLength, maxLength }) for variable-length.
- runs: 100maxShrinks
- : 1000maxCommands
- : 20 (model-based testing)gen.string(length)
- : 8gen.array(item, length)
- : 5
Typefuzz prioritises deterministic generation and shrinking. Shrinkers try smaller sizes first and then smaller values; the shrink budget (maxShrinks`) bounds the total attempts.