Skip to content

quickcheck: Arbitrary is derivable but Shrink is not #4079

Description

@bobzhang

Is your feature request related to a problem? Please describe.

check requires both Arbitrary and Shrink, but only one of them can be derived:

pub struct Point { x : Int; y : Int } derive(Debug, @qc.Arbitrary)          // ok
pub struct Point { x : Int; y : Int } derive(Debug, @shrink.Shrink)         // error
// Don't know how to derive trait @shrink.Shrink for type Point

So the path of least resistance for a custom type is a hand-written no-op instance, which silently disables shrinking — the counterexample keeps irrelevant noise. Same seed, same property shape:

derived Arbitrary + no-op Shrink -> Falsified(counterexample={ x: 3, y: -8 }, shrinks=0, shrink_attempts=0)
Int (real Shrink instance)       -> Falsified(counterexample=3,              shrinks=0, shrink_attempts=2)

The y: -8 is unrelated to the failing predicate and would be minimized away by any structural shrinker. Nothing warns that shrinking is inert.

Describe the solution you'd like

derive(Shrink) for structs and enums:

  • struct: shrink one field at a time, holding the others fixed;
  • enum: shrink the payload within a constructor, optionally toward earlier constructors.

Important: for recursive types, field-wise shrinking alone is close to useless — it only ever shrinks leaves, so the structure never gets smaller and counterexamples stay large. A derived instance should also replace a node with its same-typed subterms (what Haskell's genericShrink does via subterms); the compiler knows which fields are Self-typed, and subterms are structurally smaller so well-foundedness is preserved. I would argue the feature is only worth shipping with that included.

Describe alternatives you've considered

Hand-writing instances (what we did — 97 lines for a two-type model, most of it mechanical field/entry plumbing), or accepting the no-op and losing shrinking.

Additional context

I understand this is a compiler-side change rather than a core-library one: derive is a closed set in moonc (a user-defined trait cannot be derived at all), and the table is Arbitrary, Compare, Debug, Default, FromJson, Hash, Show, ToJson. Filing here because that is where quickcheck lives and where the pain is felt — please move it if the compiler repo is the right home. Arbitrary already being in that table suggests the incremental cost is a table entry plus a codegen rule shaped like Arbitrary's.

Note that #4076 (Shrink for Map) blocks derive from being useful on map-shaped models regardless.

Verified against the 2026-08-11 toolchain. Related: #4076, #4077, #4078.

  • I do think that this should be part of the core library and should be delivered to all the MoonBit developers.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions