Add Psalm generics across ArbitraryInterface and Shrinkable#15
Merged
Conversation
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.
This was referenced Jul 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Psalm generics for the whole public surface: the
ArbitraryInterface/Shrinkablefoundation, all 21Arbitrary/*classes, and theGenfacade — so inference reaches consumer code (Gen::map(Gen::intBetween(0, 5), fn(int $n): string => ...)isMappedArbitrary<int, string>, closure params check against the inner arbitrary's type).What changed
ArbitraryInterface<TValue>,Shrinkable<TValue>— foundation templates (invariant, see below)Arbitrary/*classes declare@implements ArbitraryInterface<T>:IntArbitrary<int>,FloatArbitrary<float>,BoolArbitrary<bool>,StringArbitrary/CharsetStringArbitrary/BytesArbitrary<string>,UuidArbitrary<non-empty-string>,DateTimeArbitrary<DateTimeImmutable>MappedArbitrary<TInner, TOutput>,FlatMappedArbitrary<TInner, TOutput>,FilteredArbitrary<TInner>,ConstantArbitrary<TValue>,OneOfArbitrary<TValue>,FrequencyArbitrary<TValue>ArrayArbitrary<TElement>→list<TElement>,UniqueArrayArbitrary<TElement>,DictionaryArbitrary<TKey of array-key, TValue>→array<TKey, TValue>CommandSequenceArbitrary<CommandSequence>Genfacade typed end-to-end:map/flatMap/filter/arrayOf/nonEmptyArrayOf/uniqueArrayOf/dictOf/oneOf/elements/constant/enum/frequency/draw/sample/sampleShrinks/regex/stringMatchingcarry@template;uuid/email/urlproducenon-empty-string; leaf factories (int,bool,string, ...) need nothing — their concrete return classes already carry the typeVariance: 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), andgenerate(): Shrinkable<TValue>(a covariant template cannot parameterize an invariant class in a return position). Consequences:ArbitraryInterface, notArbitraryInterface<mixed>: under invariance a<mixed>parameter rejects every concretely-typed arbitrary (ArbitraryInterface<int>is not a subtype ofArbitraryInterface<mixed>). Applies toRecordArbitrary::__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.(string) $n→numeric-string) will not satisfy an explicitly-typedArbitraryInterface<string>parameter on the consumer's side. Widen the closure return type annotation when this bites.NullableArbitrarystaysArbitraryInterface<mixed>:?TInnerwidening conflicts with the invariantShrinkable. 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
RemoveNonExistingVarAnnotationRectorstrips standalone@varbeforereturn, so casts must bind to variables):BoolArbitrary::generatebuilds the shrink list up front (leaf(false)alone can't type asShrinkable<bool>under invariance)FlatMappedArbitrary/NullableArbitraryextract their shrink generators into private methodsGen::draw/frequency/regex/sample/sampleShrinksreturn via a@var-annotated variableGen::jsonandUuidArbitrarykeep their original bodies (earlier restructure reverted). All 627 tests / 270341 assertions pass unchanged.Verification
composer build: green — 627 tests / 270341 assertionscomposer psalm --no-cache: no errors; consumer-style probe confirms inference flows and typed arbitraries pass the sinkscomposer rector(dry-run): cleanSemVer
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>/ typedCommandSequenceare 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).