registry-scalacheck

Derives ScalaCheck Gen[T] instances from registered leaf generators. Instead of hand-wiring for { … } yield … for every case class, register one gen[T] per type and ask the registry for Gen[T]. The macro inspects T’s primary constructor (or sealed-trait variants) and emits the entry that sequences inputs via flatMap/map.

The walkthrough below builds up a generator registry from the simplest case to recursive types and shared samples. For an at-a-glance API reference, see the Cheat sheet.

Setup

The mdoc helper used below samples a Gen deterministically with a fixed seed:

import org.scalacheck.{Gen, rng}
def sample[A](g: Gen[A]): A =
  g.pureApply(Gen.Parameters.default, rng.Seed(42L))

Deriving a Gen[T] for a case class

import registry.*
import registry.scalacheck.*

case class Address(street: String, zip: Int)
case class Person(name: String, age: Int, address: Address)

val r =
  gen[Person] +:
    gen[Address] +:
    gen(Gen.alphaStr) +:
    gen(Gen.choose(0, 120)) +:
    gen(Gen.choose(10000, 99999))
sample(r.makeGen[Person])
// res0: Person = Person(
//   name = "FvlhjKViMdsWQsLnxhZBfGsJOjZFcubqkzndbmKTlYENcyNUcbgpVXYouhedbBNtsWQcLx",
//   age = 71,
//   address = Address(street = "oDfVhuQapx", zip = 29)
// )

gen[Person] walks Person’s primary constructor (name: String, age: Int, address: Address) and registers an entry that needs Gen[String], Gen[Int], Gen[Address]. The registry resolves each via LIFO; the right-hand entries supply the leaves.

For class-internal types you’d usually want a single Gen[Int] shared, but in this case name and zip both need Gen[Int] resolutions of different ranges. The two gen(Gen.choose(…)) entries are distinguished only by registration order — the second one wins for the inner Address.

Value-driven gen(...)

case class Tagged(label: String, value: Int)

val tagged =
  gen[Tagged] +:
    gen("FIXED") +:                     // constant — Gen.const("FIXED")
    gen(Gen.choose(1, 10))              // existing ScalaCheck Gen
sample(tagged.makeGen[Tagged])
// res1: Tagged = Tagged(label = "FIXED", value = 2)

The macro inspects the value passed to gen(...):

  • a function value → its parameter types become Gen[…] inputs;
  • a Gen[T] → registered as a zero-input Gen[T] entry;
  • anything else → wrapped via Gen.const.

You can also pass an eta-expanded constructor reference: gen(Person.apply).

refineGen[Path](v) — path-scoped generator overrides

refineGen is the ScalaCheck-flavored version of core refine: each element of Path is interpreted as a generated type, and the refined payload type is inferred from the value you pass.

case class User(name: String, age: Int)

val users =
  gen[User] +:
    gen(Gen.alphaStr) +:
    gen(Gen.choose(0, 120))
sample(users.refineGen[User]("eric").makeGen[User])
// res2: User = User(name = "eric", age = 103)
sample(users.refineGen[User](Gen.choose(18, 99)).makeGen[User])
// res3: User = User(
//   name = "FvlhjKViMdsWQsLnxhZBfGsJOjZFcubqkzndbmKTlYENcyNUcbgpVXYouhedbBNtsWQcLx",
//   age = 58
// )

Plain values are lifted with Gen.const; existing Gen[T] values are used as-is. The standalone form composes like any other refinement:

sample((refineGen[User]("standalone") +: users).makeGen[User])
// res4: User = User(name = "standalone", age = 103)

For multi-step scopes, use a tuple path just like core refine: r.refineGen[(Outer, User)]("nested").

arb[T] — from an in-scope Arbitrary

import org.scalacheck.Arbitrary

case class Stamp(id: Int)

val stamps =
  gen[Stamp] +: arb[Int]
sample(stamps.makeGen[Stamp])
// res5: Stamp = Stamp(2147483647)

Equivalent to gen(Arbitrary.arbitrary[Int]) but lighter at the call site — the typical pattern when leaves are already supplied by implicit Arbitrary instances.

Sealed traits, enums, sum types

gen[T] on a sealed type expands into a registry bundling genTrait, one entry per variant, and a default Chooser.uniform.

sealed trait Animal
case class Dog(name: String) extends Animal
case class Cat(lives: Int)   extends Animal

val zoo =
  gen[Animal] +:
    gen(Gen.alphaStr) +:
    gen(Gen.choose(1, 9))
sample(zoo.makeGen[Animal])
// res6: Animal = Cat(7)

To control the variant distribution, swap the registered ChoosergenTrait[T] consumes whatever Chooser is in scope:

val mostlyDogs =
  genTrait[Animal] +:
    gen[Dog] +:
    gen[Cat] +:
    value(Chooser.weighted(9, 1)) +:    // 9× more Dogs than Cats
    gen(Gen.alphaStr) +:
    gen(Gen.choose(1, 9))
val samples =
  (0 until 50).map(i =>
    mostlyDogs.makeGen[Animal].pureApply(Gen.Parameters.default, rng.Seed(i.toLong))
  )
samples.count(_.isInstanceOf[Dog])
// res7: Int = 48
samples.count(_.isInstanceOf[Cat])
// res8: Int = 2

Chooser.weighted(...) matches Mirror.SumOf[T].MirroredElemTypes order; Chooser.only(i) always picks the i-th variant (useful for deterministic tests of one branch).

Container helpers

val ints =
  listOfN[Int](5) +:
    gen(Gen.choose(0, 100))
sample(ints.makeGen[List[Int]])
// res9: List[Int] = List(70, 100, 94, 72, 100)

Each helper registers a 1-input entry that consumes Gen[T] (or two inputs for eitherOf / pairOf / mapOf etc.) and produces the container Gen. Mix them freely with gen[T] and the rest of the API.

Recursive generators

genRec[T] registers a Gen[T] → Gen[T] entry. Its recursive input is satisfied by another Gen[T] producer below — typically a base case (see Resolution for the “skip-in-flight-entries” mechanism).

sealed trait Tree
case object Leaf                              extends Tree
case class Node(left: Tree, right: Tree)      extends Tree

val trees =
  genRec[Tree](maxSize = 3) { self =>
    Gen.zip(self, self).map((l, r) => Node(l, r): Tree)
  } +:
    gen(Leaf: Tree)
sample(trees.makeGen[Tree])
// res10: Tree = Node(
//   left = Node(left = Leaf, right = Leaf),
//   right = Node(left = Leaf, right = Leaf)
// )

Without the base entry the recursive lookup would cycle. With it, the resolver picks the base when the recursive entry is in flight, and ScalaCheck’s Gen.recursive + Gen.sized does the depth control.

Tuning the recursion: Sized

genRec[T] doesn’t just register the recursive entry — it bundles a value(Sized.default) alongside it. Sized exposes two knobs:

Knob Type What it controls
pickBase(size) Int => Gen[Boolean] At the current size, return true for “use base” or false for “recurse via grow”.
nextSize(size) Int => Gen[Int] Compute the size to pass to Gen.resize for the recursive call.

Sized.default matches the behavior of earlier versions: at size <= 0 always pick the base (terminates), otherwise a 1:3 weighted choice in favor of recursion; size shrinks by 1 each step.

Override by prepending your own value(mySized) — LIFO selection picks it over the default that genRec injects:

val onlyBase = Sized(
  pickBase = _ => Gen.const(true),
  nextSize = size => Gen.const((size - 1).max(0))
)

val flatTrees =
  value(onlyBase) +:
    genRec[Tree] { self =>
      Gen.zip(self, self).map((l, r) => Node(l, r): Tree)
    } +:
    gen(Leaf: Tree)
sample(flatTrees.makeGen[Tree])
// res11: Tree = Leaf

Common shapes:

  • Sized.default — 1:3 base/grow, size − 1.
  • “Always grow” — pickBase = size => if size <= 0 then Gen.const(true) else Gen.const(false) to always recurse until the size guard kicks in.
  • Wider distribution — nextSize = size => Gen.choose(0, size - 1) for varied recursive depths.
  • Halve-on-recurse — nextSize = size => Gen.const(size / 2).

A custom Sized.pickBase is responsible for terminating at low sizes; the recursion has no other depth guard. Always returning false from pickBase will overflow the stack.

Sharing one sample across a tree

By default, Gen[T] consumed in two positions of a generated value samples independently — each flatMap step uses its own seed segment. When two positions should observe the same sampled value, mark the underlying entry shared:

case class Bundle(a: Int, b: Int)

val pinned =
  gen[Bundle] +: gen(Gen.choose(0, 1_000_000)).share
sample(pinned.makeGen[Bundle])
// res12: Bundle = Bundle(a = 235804, b = 235804)

Both fields draw the same Int. The registry-level form share[Int] +: r flips the flag retroactively on any matching entry; use it when the shared entry is registered elsewhere (e.g. via gen[T]):

val pinnedRetro =
  share[Int] +:
    gen[Bundle] +:
    gen(Gen.choose(0, 1_000_000))
sample(pinnedRetro.makeGen[Bundle])
// res13: Bundle = Bundle(a = 235804, b = 235804)

const[T] is share[T] with cross-build pinning: every makeGen call on the registry observes the same sampled value, regardless of seed — useful for “fixture” data shared across an entire test run.

memoize[T] is the lightest — it caches the produced Gen instance across makeGen calls but does no sample-time pinning.

The same flags are also available as call-site extensions on the registry, useful when the registry is built once (e.g. as a def) and each consumer chooses which types to pin without modifying the construction site:

val gens = baseRegistry.const[MultiNodeConfig].share[Onchain]
// equivalent to:
val gens2 = const[MultiNodeConfig] +: share[Onchain] +: baseRegistry

See Memoization for the underlying machinery (per-make resolver cache, the shared flag on GenEntry, the build path that prepends a Gen.const(sample) entry per shared type).

Resetting pinned state with reset()

memoize[T] and const[T] install AtomicReference-backed caches inside the registry’s entries. Those caches survive the registry value (closures capture them) — so a lazy val voteGens = ... shared across multiple property tests would carry the first test’s pinned values into every subsequent test.

r.reset() mutates each entry in place, clearing every memoized / const- pinned value without rebuilding the registry. Call it at the start of a property to give that property fresh fixture samples while still letting the const pins do their job for the property’s iterations:

class MyTest extends Properties("…"):
  val gens = voteGens                  // shared registry — see voteGens above
  val _ = property("Vote Tx") = {
    gens.reset()                       // fresh MultiNodeConfig, etc., for this property
    runDefault(/* … forAll … */)
  }
  val _ = property("Tally Tx") = {
    gens.reset()                       // and again, independent of the previous property
    runDefault(/* … forAll … */)
  }

Entries that don’t hold mutable state (the default Basic / GenEntry without memoize / const wrapping) are unaffected — reset() is a no-op on them.

share vs const when both are used

share and const are NOT interchangeable when one type transitively contains another. Suppose Onchain has a versionMajor: V field built via gen(genOnchainBlockHeader) that consumes Gen[V]:

const[Onchain] +:    // pin Onchain's value across the entire run
share[V]      +:     // re-sample V per makeGen build
gen(genOnchainBlockHeader) +:
gen(genV)

const[Onchain] pins one Onchain forever — including the V baked into its versionMajor. share[V] re-samples V per build. Other consumers of V see fresh samples; the const-pinned Onchain keeps the iter-1 V forever. The two views diverge.

If you only need intra-tree consistency (the same Onchain inside one makeGen call, fresh per call), use share[Onchain] — it composes with share[V] cleanly. Reach for const only when you genuinely want one value to outlive all builds. r.reset() lets you step back when needed.

makeGen[T] vs make[Gen[T]]

makeGen[T] is share-aware. When any entry is a GenEntry with shared = true, it samples each shared Gen[A] once at the outer level and prepends a value-style entry producing Gen.const(sample) before resolving the rest. When no entry is shared, it delegates to plain r.make[Gen[T]].

In practice, always reach for makeGen[T] — it’s a no-op on registries without sharing and the right thing on registries with it.

  • Cheat sheet — every factory and combinator in one place, for quick lookup once you know the shape.
  • Memoization — sharing across consumers, per-make resolver cache, the shared flag and the share build path.
  • Resolution — the recursive-entry mechanism that powers genRec.
  • Customization — core refine for context-scoped overrides; use refineGen for generated payloads.

Table of contents


This site uses Just the Docs, a documentation theme for Jekyll.