Skip to content

Add Psalm generics across ArbitraryInterface and Shrinkable#15

Merged
rasuvaeff merged 2 commits into
masterfrom
feat/psalm-generics
Jul 18, 2026
Merged

Add Psalm generics across ArbitraryInterface and Shrinkable#15
rasuvaeff merged 2 commits into
masterfrom
feat/psalm-generics

Conversation

@rasuvaeff

@rasuvaeff rasuvaeff commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Psalm generics for the whole public surface: the ArbitraryInterface/Shrinkable foundation, all 21 Arbitrary/* classes, and the Gen facade — so inference reaches consumer code (Gen::map(Gen::intBetween(0, 5), fn(int $n): string => ...) is MappedArbitrary<int, string>, closure params check against the inner arbitrary's type).

What changed

  • ArbitraryInterface<TValue>, Shrinkable<TValue> — foundation templates (invariant, see below)
  • All 21 Arbitrary/* classes declare @implements ArbitraryInterface<T>:
    • Leaf: IntArbitrary<int>, FloatArbitrary<float>, BoolArbitrary<bool>, StringArbitrary/CharsetStringArbitrary/BytesArbitrary<string>, UuidArbitrary<non-empty-string>, DateTimeArbitrary<DateTimeImmutable>
    • Combinators: MappedArbitrary<TInner, TOutput>, FlatMappedArbitrary<TInner, TOutput>, FilteredArbitrary<TInner>, ConstantArbitrary<TValue>, OneOfArbitrary<TValue>, FrequencyArbitrary<TValue>
    • Collections: ArrayArbitrary<TElement>list<TElement>, UniqueArrayArbitrary<TElement>, DictionaryArbitrary<TKey of array-key, TValue>array<TKey, TValue>
    • Stateful: CommandSequenceArbitrary<CommandSequence>
  • Gen facade typed end-to-end: map/flatMap/filter/arrayOf/nonEmptyArrayOf/uniqueArrayOf/dictOf/oneOf/elements/constant/enum/frequency/draw/sample/sampleShrinks/regex/stringMatching carry @template; uuid/email/url produce non-empty-string; leaf factories (int, bool, string, ...) need nothing — their concrete return classes already carry the type

Variance: invariant by necessity

The roadmap asked for @template-covariant. Verified empirically — Psalm rejects it (InvalidTemplateParam) on two independent grounds: Shrinkable::map(Closure(TValue): TOutput) (Psalm does not compose variance through a closure parameter, even though the position is theoretically covariant), and generate(): Shrinkable<TValue> (a covariant template cannot parameterize an invariant class in a return position). Consequences:

  • Heterogeneous sinks take bare ArbitraryInterface, not ArbitraryInterface<mixed>: under invariance a <mixed> parameter rejects every concretely-typed arbitrary (ArbitraryInterface<int> is not a subtype of ArbitraryInterface<mixed>). Applies to RecordArbitrary::__construct, TupleArbitrary, Gen::record/tuple, and the command-generator lists (Gen::commands, CommandSequenceArbitrary — a generator typed with a concrete command class must be accepted). Runtime validation of the actual contract is unchanged.
  • Known sharp edge for consumers: a closure inferred to a narrower type ((string) $nnumeric-string) will not satisfy an explicitly-typed ArbitraryInterface<string> parameter on the consumer's side. Widen the closure return type annotation when this bites.
  • NullableArbitrary stays ArbitraryInterface<mixed>: ?TInner widening conflicts with the invariant Shrinkable. Nullable consumers usually type the property parameter (?int $n), so the practical loss is small.

Runtime deltas (behavior-preserving)

Not a pure-docblock diff — three method bodies are restructured where Psalm/rector left no annotation-only path (rector's RemoveNonExistingVarAnnotationRector strips standalone @var before return, so casts must bind to variables):

  • BoolArbitrary::generate builds the shrink list up front (leaf(false) alone can't type as Shrinkable<bool> under invariance)
  • FlatMappedArbitrary/NullableArbitrary extract their shrink generators into private methods
  • Gen::draw/frequency/regex/sample/sampleShrinks return via a @var-annotated variable

Gen::json and UuidArbitrary keep their original bodies (earlier restructure reverted). All 627 tests / 270341 assertions pass unchanged.

Verification

  • composer build: green — 627 tests / 270341 assertions
  • composer psalm --no-cache: no errors; consumer-style probe confirms inference flows and typed arbitraries pass the sinks
  • composer rector (dry-run): clean
  • BC: docblock-only for the public contract; CI bc-check vs v2.5.0

SemVer

Docblock change — not semver-relevant on its own. Rides the next feature release (2.6.0 = generics + diagnostics + deadline). No tag on this merge. Command<TModel, TSystem, TResult> / typed CommandSequence are deliberately NOT typed here — heterogeneous command lists hit the same invariance wall; deferred in the roadmap.

Roadmap: yii3-package-plans/property-testing-roadmap.md (rasuvaeff/rasuvaeff repo).

Pure docblock annotations — runtime is unchanged. The public API gains
@template TValue (and TKey/TElement/TInner/TOutput as appropriate) so
consumers get IDE inference and Psalm checks on composed generators
(Gen::map, flatMap, filter, arrayOf, dictOf, tuple, ...).

- ArbitraryInterface<TValue>, Shrinkable<TValue> as the foundation
- 17 Arbitrary/* classes declare @implements ArbitraryInterface<T>
- Gen facade carries @template/@param/@return per static factory
- NullableArbitrary stays ArbitraryInterface<mixed> (variance issue with
  ?TInner would require covariant TValue that breaks Shrinkable::map);
  Record/Tuple use array<string,mixed>/list<mixed> as practical approximations
- Defensive runtime checks preserved via @var mixed widening so Psalm
  no longer flags them as DocblockTypeContradiction

make build green (627 tests), make psalm clean, make bc-check clean vs v2.4.0.
- Gen combinators (map, flatMap, filter, arrayOf, nonEmptyArrayOf,
  uniqueArrayOf, oneOf, elements, constant, enum, frequency, draw, sample,
  sampleShrinks, regex, stringMatching, domain generators) now carry
  templates, so inference reaches consumer code.
- Heterogeneous sinks (RecordArbitrary shape, command generators) take bare
  ArbitraryInterface: under invariant templates a <mixed> parameter rejects
  every concretely-typed arbitrary.
- uuid/email/url declare non-empty-string (what Psalm infers).
- Reverted the Gen::json and UuidArbitrary body restructures from the
  previous commit; dropped a redundant @var bool.
@rasuvaeff
rasuvaeff merged commit cac5f90 into master Jul 18, 2026
7 checks passed
@rasuvaeff
rasuvaeff deleted the feat/psalm-generics branch July 18, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant