Arbitraries
An arbitrary is a fast-check generator that produces random test data. Corpus provides two ways to get them: derive from Zod schemas, or register custom generators for your domain types.
Deriving from Zod Schemas
The simplest path: pass any Zod schema to testing.arb() and get an fc.Arbitrary that generates valid values.
import { z } from "zod";import { testing } from "@f0rbit/corpus";
const UserSchema = z.object({ id: z.string().uuid(), name: z.string().min(1), email: z.string().email(), age: z.number().int().min(0).max(150).optional(),});
const user_arb = testing.arb(UserSchema);
// Use it in a property testawait testing.law.round_trip( user_arb, (user) => JSON.stringify(user), (json) => JSON.parse(json),);The walker understands ~14 common Zod kinds:
- Primitives:
string(),number(),bigint(),boolean(),date() - Constraints:
uuid(),email(),regex(),min(),max(),length() - Combinators:
object(),array(),tuple(),record(),union(),discriminatedUnion() - Modifiers:
optional(),nullable(),default(),branded(),lazy() - Special:
any(),unknown()
For string() with custom constraints (e.g., datetime()), the walker generates domain-specific strings that will pass validation.
Composing Dependent Generators
Sometimes you need later test data to depend on earlier data — e.g., generating an array whose length comes from a prior draw.
Use testing.compose() for inline dependent generation:
const order_arb = testing.compose((draw) => { const id = draw(testing.arb(OrderIdSchema)); const count = draw(testing.fc.integer({ min: 1, max: 10 })); const items = draw( testing.fc.array(testing.arb(ItemSchema), { minLength: count, maxLength: count, }) ); return { id, items, item_count: items.length };});
// Use it like any other arbitraryawait testing.law.round_trip(order_arb, encode, decode);The draw function is synchronous — it pulls a single value from the arbitrary you pass. You can nest draws: draw depends on a prior draw, and so on.
Registering Custom Generators
If testing.arb(schema) doesn’t fit — you need a hand-tuned generator, or you want to ensure the same generator is reused everywhere — register it once:
Branded Type Registration
Use a branded symbol for identity-bearing types (UserId, OrderId, Money, error types):
import { testing } from "@f0rbit/corpus";import { type ArbBrand } from "@f0rbit/corpus/testing";
type UserId = string & { readonly __brand: "UserId" };const USER_ID_BRAND = Symbol("UserId") as ArbBrand<UserId>;
// Register once (e.g., in a test file or registrar)testing.arbitrary( USER_ID_BRAND, testing.fc.uuid().map((id) => id as UserId));
// Now every test using USER_ID_BRAND gets the same generatorconst user_id = await testing.lookup(USER_ID_BRAND);Zod Schema Registration
For ad-hoc schemas without a long-lived brand, register the schema instance directly:
const UserSchema = z.object({ id: z.string().uuid(), name: z.string(),});
// Register this specific schema instancetesting.arbitrary( UserSchema, testing.fc.record({ id: testing.fc.uuid(), name: testing.fc.string({ minLength: 1 }), }));
// Later lookups for this schema instance get the registered generatorconst user_arb = await testing.lookup(UserSchema);Looking Up Registered Generators
Once a generator is registered (either via the registry or a dependency’s registrar), use lookup() to retrieve it:
// Returns a Promise<Arbitrary<T> | undefined>// If the generator is registered, you get it; otherwise undefinedconst user_id_gen = await testing.lookup(USER_ID_BRAND);if (user_id_gen) { // Use the canonical generator await testing.law.round_trip(user_id_gen, encode, decode);}Hybrid Registry
The registry uses two key spaces:
-
Branded symbols — For identity-bearing types where you want a single canonical generator reused everywhere.
- Register:
testing.arbitrary(BRAND, gen) - Look up:
await testing.lookup(BRAND)
- Register:
-
Zod schema instances — For ad-hoc schemas without a brand.
- Register:
testing.arbitrary(schema, gen) - Look up:
await testing.lookup(schema)
- Register:
Failure Generators for Error Variants
When testing error paths (see the error_path_exhaustive law), you need generators that produce specific error variants. Register failure generators the same way:
// Import the canonical brand — never mint your own Symbol for CorpusErrorimport { CORPUS_ERROR_BRAND } from "@f0rbit/corpus/testing";
// Register a generator for the "not_found" varianttesting.failure( CORPUS_ERROR_BRAND, "not_found", testing.compose((draw) => ({ kind: "not_found" as const, store_id: draw(testing.fc.string({ minLength: 1 })), version: draw(testing.fc.string({ minLength: 1 })), })));
// Look it upconst gen = await testing.lookup_failure(CORPUS_ERROR_BRAND, "not_found");Handling Unsupported Schemas
If you use testing.arb() with an unsupported schema kind, you get a clear error:
Error: unsupported Zod kind: ZodEffects Manually register via testing.arbitrary(schema, gen): testing.arbitrary(mySchema, fc.integer().map(yourTransform))The escape hatch is testing.arbitrary(schema, gen) — pass your schema and a hand-built generator. After registering, arb(schema) will use the registered generator.
const EffectsSchema = z.string().pipe(z.string().email());
// Register a custom generatortesting.arbitrary( EffectsSchema, email_arb() // your custom arbitrary);
// Now arb() finds and returns itconst gen = testing.arb(EffectsSchema);Registrar Modules
For sharing arbitraries across packages, see Vending. The core idea: each package declares a register.js file in package.json that calls testing.arbitrary() and testing.failure(), and the registry auto-loads them.