Laws
Laws are pre-built property checkers that verify your code respects a contract. Corpus ships five laws covering serialization, idempotence, functor structure, error exhaustiveness, and cross-implementation equivalence.
Round Trip
Contract: decode(encode(x)) === x for all x.
Use this for any codec, serializer, or bidirectional transformation.
import { testing, text_codec } from "@f0rbit/corpus";
const text_codec = { encode: (x: string) => new TextEncoder().encode(x), decode: (bytes: Uint8Array) => new TextDecoder().decode(bytes),};
await testing.law.round_trip( testing.fc.string(), text_codec.encode, text_codec.decode);The law generates 100 random strings by default, encodes each, decodes it, and asserts the result equals the original. Both encode and decode may be sync or async — the law awaits them.
Options:
numRuns— property run count; default 100equals— custom equality check; defaults toBun.deepEqualsunder Bun (required on Node)
await testing.law.round_trip( testing.arb(UserSchema), user => JSON.stringify(user), json => JSON.parse(json), { equals: (a, b) => a.id === b.id && a.name === b.name, numRuns: 200, });Idempotent
Contract: fn(fn(x)) === fn(x) for all x.
Use this for normalizers, canonicalizers, or any function that should stabilize after one application.
import { testing } from "@f0rbit/corpus";
const normalize_path = (p: string) => p.replace(/\/+/g, "/");
await testing.law.idempotent( testing.fc.string(), normalize_path);The law applies the function twice and asserts both results are equal.
Options:
numRuns— default 100equals— custom equality; defaults toBun.deepEquals
Functor
Contract: Structure is preserved through mapping: x.map(f).map(g) === x.map(g ∘ f) for all x, f, g.
Use this for types that implement the functor laws (arrays, optionals, Results, Promises, etc.).
import { testing } from "@f0rbit/corpus";
const arr = [1, 2, 3];const f = (x: number) => x * 2;const g = (x: number) => x + 1;
await testing.law.functor({ arb: testing.arb(ArraySchema), map: (arr, fn) => arr.map(fn), equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),});The law generates random values, applies two different function compositions, and asserts they agree.
Options:
numRuns— default 100arb— the arbitrary to generate test valuesmap(value, fn)— the map operationequals— custom equality
Error Path Exhaustive
Contract: Every variant of a function’s error union can be provoked and is tested.
The headline law: compile-time exhaustiveness (TypeScript enforces you handle every variant) + runtime verification (every variant has a generator and actually fails correctly).
import { testing } from "@f0rbit/corpus";import { CORPUS_ERROR_BRAND } from "@f0rbit/corpus/testing";import type { Result } from "@f0rbit/corpus";
// Example: a function that returns multiple error kindsasync function get_user( id: string, opts?: { timeout?: number }): Promise<Result<User, CorpusError>> { // Can return: not_found, decode_error, timeout, or ok}
await testing.law.error_path_exhaustive(get_user, { error_brand: CORPUS_ERROR_BRAND, provoke: { not_found: (f) => [f.user_id], decode_error: (f) => [corrupt_bytes(f)], timeout: (f) => [undefined, { timeout: f.duration }], // TypeScript enforces every variant is listed here }, only: ["not_found", "decode_error"], // optional: test only a subset numRuns: 200,});How it works:
-
TypeScript’s mapped type
provoke: { [K in E["kind"]: ... }enforces you provide a handler for every error variant. If you add a variant without updatingprovoke, it’s a compile error. -
For each variant (or the
onlysubset), the law:- Looks up the failure generator via
testing.lookup_failure(brand, variant_kind) - Generates failure inputs from that generator
- Calls your function with those inputs
- Asserts it returns an
errwith the matchingkind
- Looks up the failure generator via
-
If a generator is missing, you get a clear error naming the exact registration call needed:
error_path_exhaustive: no generator registered for variant 'not_found' of brand Symbol(CorpusError)— register via testing.failure(CORPUS_ERROR_BRAND, 'not_found', () => ...)
Options:
error_brand— the branded symbol for your error unionprovoke— mapped-type object with one entry per error variant; each entry is a function that takes a failure instance and returns the args to call your function withonly— optional subset of variants to test (useful during development)numRuns— per-variant run count; default 50
Provider Equivalence
Contract: Multiple implementations (or backends) behave the same way under the same command sequence.
The most powerful law: run fast-check’s stateful command generator against a reference model and N systems-under-test (providers), and assert per-command that results agree.
import { testing } from "@f0rbit/corpus";
interface Counter { increment(): Promise<Result<number, CounterError>>; reset(): Promise<Result<void, CounterError>>;}
type CounterModel = { count: number };
const increment_cmd = testing.law.equivalence_command<CounterModel, Counter, Result<number, CounterError>>({ label: "increment()", on_model: (m) => { if (m.count >= 100) return err({ kind: "overflow" }); m.count += 1; return ok(m.count); }, on_sut: (c) => c.increment(),});
const reset_cmd = testing.law.equivalence_command<CounterModel, Counter, void>({ label: "reset()", on_model: (m) => { m.count = 0; }, on_sut: (c) => c.reset(),});
await testing.law.provider_equivalence({ model: () => ({ count: 0 }), providers: { memory: () => new MemoryCounter(), persistent: () => new FileBackedCounter(), }, commands: [increment_cmd, reset_cmd], equivalence: { results_agree: (a, b) => a === b, }, numRuns: 50, maxCommands: 20,});How it works:
-
Fast-check generates a shrinkable sequence of commands — the same sequence runs against the model AND each provider.
-
Each command runs twice:
on_model(model)— synchronous reference implementationon_sut(sut)— the system-under-test (may be async)
-
After every command, results are compared:
- If both are Result-shaped:
okflags must agree; when both ok, values must satisfyresults_agree; when both err,error.kindmust match - Otherwise: results must satisfy
results_agree
- If both are Result-shaped:
-
If results diverge, the property fails with the provider label, command index, and the minimal shrunk command sequence — fast-check’s native command shrinker finds the smallest reproduction.
Using equivalence_command:
const cmd = testing.law.equivalence_command({ label: "operation name", check: (m) => precondition(m), // optional: only run if true on_model: (m) => operation_on_model(m), on_sut: (sut) => operation_on_sut(sut), // may be async});The command carries both results and compares them automatically.
Options:
model()— factory returning a fresh model instance per run (must return fresh, not reused)providers— labelled object of provider factories (one per SUT)commands— pool ofequivalence_commandinstances and/or arbitraries of commandsequivalence.results_agree(model_r, sut_r)— equality check for ok-values (or raw results when non-Result-shaped)numRuns— property runs; default 100maxCommands— max commands per generated sequence; default depends on fast-check’s sizingsize— bias for sequence length; default uses fast-check’s heuristics
Common Patterns
Testing with Coverage Labels
Use testing.cover() (Hedgehog-style) to assert parts of your test space got exercised:
await testing.law.round_trip( testing.compose((draw) => { const x = draw(testing.fc.integer()); testing.cover(x > 0, "positive"); testing.cover(x < 0, "negative"); testing.cover(x === 0, "zero"); return x; }), encode, decode, { numRuns: 200 });
// If the labels don't hit, the law throws with coverage statsOpting Out of Shrinking
The round_trip, idempotent, and error_path_exhaustive laws disable shrinking (endOnFailure: true) by default. This is intentional: codecs and failure generators may rely on subtle interactions that break under partial shrinks, and testing.compose uses fc.gen() whose shrinker doesn’t reliably terminate.
If you need shrinking, use fc.assert directly with a plain fast-check property.
Custom Equality
Several laws accept an equals option for custom value comparison:
await testing.law.round_trip( arb, encode, decode, { equals: (a, b) => { // Ignore the `_internal_cache` field const { _internal_cache: _, ...a_clean } = a; const { _internal_cache: __, ...b_clean } = b; return JSON.stringify(a_clean) === JSON.stringify(b_clean); }, });Under Bun, round_trip and idempotent default to Bun.deepEquals. On Node or workerd, you must provide equals or the law throws an error.