gen.optional: absent-or-value without colliding with null #2

Closed
opened 2026-09-13 20:04:00 +09:00 by natsukium · 2 comments
Owner

Missing

gen.optional g (or gen.maybe) producing "absent" or a value, with a representation that does not collide with null as a legitimate value.

Where it was needed

jsonschema2nix draws objects where each property may be unset, set to null (nullable schemas) or set to a value. Two hand-rolled encodings coexist in tests/generators.nix because there is no primitive:

# property table: null means absent, then filtered
gen.map (entries: lib.mapAttrs (_: e: e.value) (lib.filterAttrs (_: e: e != null) entries)) (gen.record ...)

# module split: explicit presence flag
{ has = bool; value = ...; }

The first cannot express a present null, so it wraps every value in { value = ...; }; the second threads has through every consumer. Both obscure the intent and neither shrinks "present -> absent" as the natural first step.

Suggested shape

  • gen.optional g : Gen { has : bool; value : a } or Gen (null | { value }), shrinking toward absent first.
  • gen.recordWithOptional { k = g; ... } or a gen.record variant that drops absent fields, so filterAttrs disappears from callers.
## Missing `gen.optional g` (or `gen.maybe`) producing "absent" or a value, with a representation that does not collide with `null` as a legitimate value. ## Where it was needed jsonschema2nix draws objects where each property may be unset, set to `null` (nullable schemas) or set to a value. Two hand-rolled encodings coexist in `tests/generators.nix` because there is no primitive: ```nix # property table: null means absent, then filtered gen.map (entries: lib.mapAttrs (_: e: e.value) (lib.filterAttrs (_: e: e != null) entries)) (gen.record ...) # module split: explicit presence flag { has = bool; value = ...; } ``` The first cannot express a present `null`, so it wraps every value in `{ value = ...; }`; the second threads `has` through every consumer. Both obscure the intent and neither shrinks "present -> absent" as the natural first step. ## Suggested shape - `gen.optional g : Gen { has : bool; value : a }` or `Gen (null | { value })`, shrinking toward absent first. - `gen.recordWithOptional { k = g; ... }` or a `gen.record` variant that drops absent fields, so `filterAttrs` disappears from callers.
Author
Owner

Plan

Representation: null | { value = v; }. Nix has no Maybe; a { has; value } tag has to be threaded through every consumer, whereas if o == null then … else o.value is the shortest pattern match and does not collide with a present null.

Generation as Hedgehog Gen.maybe:

optional = g: sized (n: frequency [ [ 2 (constant null) ] [ (1 + n) (map (v: { inherit value; }) g) ] ]);

Absent is common at small sizes and, because frequency shrinks toward the first alternative, "present -> absent" is the first shrink step.

Records are the main consumer, so add gen.recordWith { required = { … }; optional = { … }; } after Hypothesis fixed_dictionaries(mapping, optional=…) / fast-check fc.record(model, { requiredKeys }): absent fields are dropped, so filterAttrs disappears from callers and most of them never see the wrapper.

Depends on #6 (frequency) and #5 (sized).

## Plan Representation: `null | { value = v; }`. Nix has no `Maybe`; a `{ has; value }` tag has to be threaded through every consumer, whereas `if o == null then … else o.value` is the shortest pattern match and does not collide with a present `null`. Generation as Hedgehog `Gen.maybe`: ```nix optional = g: sized (n: frequency [ [ 2 (constant null) ] [ (1 + n) (map (v: { inherit value; }) g) ] ]); ``` Absent is common at small sizes and, because `frequency` shrinks toward the first alternative, "present -> absent" is the first shrink step. Records are the main consumer, so add `gen.recordWith { required = { … }; optional = { … }; }` after Hypothesis `fixed_dictionaries(mapping, optional=…)` / fast-check `fc.record(model, { requiredKeys })`: absent fields are dropped, so `filterAttrs` disappears from callers and most of them never see the wrapper. Depends on #6 (`frequency`) and #5 (`sized`).
Author
Owner

Implemented in 456e65c, pushed to main.

Implemented in 456e65c, pushed to main.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
natsukium/nixcheck#2
No description provided.