Compute ConstantArrayType sealed-shape isSuperTypeOf() reasons lazily via IsSuperTypeOfResult closures - #6003
Merged
ondrejmirtes merged 1 commit intoJul 4, 2026
Conversation
…zily via `IsSuperTypeOfResult` closures - Add an optional `list<Closure(): string> $lazyReasons` channel to `IsSuperTypeOfResult` plus a `getReasons()` accessor that materializes and de-duplicates them; `and()`, `or()`, `decorateReasons()`, `extremeIdentity()`, `maxMin()`, `lazyMaxMin()`, `negate()` and `toAcceptsResult()` all propagate the lazy reasons untouched. - `ConstantArrayType::isSuperTypeOf()` now passes the "Sealed array shapes … cannot be intersected" explanation as a lazy closure instead of eagerly calling `describe()` on both shapes for every sealed-vs-sealed comparison. On `tests/bench/data/bug-7581.php` this removed 22.5k wasted `describe()` builds (the reason was never rendered) and cut analysis time ~35% (≈693ms → ≈452ms in-process), erasing the regression from the "reasons" batch. - Materialize the lazy reasons at the render sites that surface them as tips: `TypeCombinator::intersect()` (both NeverType-with-reason branches), `InitializerExprTypeResolver::resolveIdenticalType()`, `ImpossibleCheckTypeHelper` and `ImpossibleInstanceOfRule`, so error output is unchanged. - Probed the sibling `accepts()` / `AcceptsResult` describe-based reasons in `ConstantArrayType`: they only run on genuine acceptance failures (0 builds on the benchmark), so they are not part of this regression and were left eager. The parallel `ObjectType` reasons were already addressed separately (0c88c32, 4be03a5).
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.
Summary
bug-7581.php(a 33-case switch that builds an array shape key by key inside aforeachconvergence loop) got ~30% slower after the June 2026 "reasons" batch
(
57ea87c83,a01080ece,e5caec56f). That batch madeConstantArrayType::isSuperTypeOf()eagerly build a
Sealed array shapes … cannot be intersectedexplanation — which calls theexpensive
describe()on both array shapes — for every sealed-vs-sealed comparison duringunion/intersection normalization, even though the reason is almost never rendered. On this
benchmark the reason string was built 22,558 times and displayed 0 times.
This PR keeps the diagnostic but computes it lazily, so the
describe()work only happens ifand when the reason is actually turned into an error tip.
Changes
src/Type/IsSuperTypeOfResult.php: added an optionallist<Closure(): string> $lazyReasonsconstructor argument (BC — defaults to
[]) and agetReasons()accessor that materializesand de-duplicates eager + lazy reasons. Every reason-carrying method (
and,or,decorateReasons,extremeIdentity,maxMin,lazyMaxMin,negate,toAcceptsResult)now threads the lazy reasons through unchanged.
createNo()gained a matching$lazyReasonsparameter and keeps returning the sharedNosingleton only when both reasonlists are empty.
src/Type/Constant/ConstantArrayType.php: the two sealed-shapeno()branches inisSuperTypeOf()now passsealedArrayShapesCannotBeIntersectedReason()as aClosure(): stringviacreateNo(lazyReasons: …)instead of building the string eagerly.src/Type/TypeCombinator.php: the twointersect()sites that turn an array intersectioninto a reason-bearing
NeverTypenow readgetReasons()so the lazy reason is materializedexactly where it is consumed.
src/Reflection/InitializerExprTypeResolver.php,src/Rules/Comparison/ImpossibleCheckTypeHelper.php,src/Rules/Classes/ImpossibleInstanceOfRule.php: switched the reason reads that feed errortips from
->reasonsto->getReasons(), so===/is_*()/instanceofimpossibility tipskeep showing the sealed-shape explanation.
tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php: new regression test.Probed and intentionally left as-is:
ConstantArrayType::accepts()/AcceptsResultdescribe-based reasons only runon genuine acceptance failures (0 builds on the benchmark), so they are not part of this
regression.
ObjectType::isSuperTypeOf()per-pair reasons were already handled separately(
0c88c321a,4be03a5a0).Root cause
The "reasons" batch attached human-readable explanations directly to the
IsSuperTypeOfResultreturned by hot type comparisons.
ConstantArrayType's explanation is expensive because itdescribe()s both (potentially large) array shapes, andisSuperTypeOf()is called O(n) timesper intersection/union step while the array shape grows, so the cost is roughly quadratic — all
of it discarded because the reason is only shown when a rule renders it. The fix makes the
reason a
Closure(): stringthat runs only whengetReasons()is called at a render site.Test
ConstantArrayTypeTest::testSealedArrayShapesCannotBeIntersectedReasonIsLazy: for twodisjoint sealed shapes it asserts
isSuperTypeOf()->reasons === [](nothing built eagerly)while
getReasons()still yields the fullSealed array shapes … cannot be intersectedmessage. This fails on the pre-fix code (where
->reasonsalready contained the eagerlybuilt string) and passes after the fix.
IncompatiblePhpDocTypeRuleTest::testExplainUnresolvablecontinues to pass,proving the tip is still surfaced end-to-end via the
NeverType/ unresolvable-type path.Comparison,ImpossibleInstanceOf,ImpossibleCheckType{Function,Method,StaticMethod}Call) and the fullType/Analysersuites remain green.
Fixes phpstan/phpstan#14918