Skip to content

Absorb refined constant scalars into their plain constant within the scalar buckets of TypeCombinator::union() - #6869

Merged
ondrejmirtes merged 4 commits into
2.3.xfrom
union-refined-constant-scalars
Oct 28, 2026
Merged

ondrejmirtes merged 4 commits into
2.3.xfrom
union-refined-constant-scalars

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #6863 (phpstan/phpstan#15591). That PR addressed the right root cause, but its implementation introduced false positives and a performance regression. This PR replaces it.

Root cause (unchanged)

TypeCombinator::union() puts every type with isConstantScalarValue()->yes() into per-class scalar buckets. Bucket members are compared only against the non-scalar union members, never against each other or against other buckets. That is correct for plain constants, because distinct values are disjoint. It is wrong for constants refined by another type: 'Foo' (ConstantStringType bucket) and 'Foo'&hasMethod(m1) (IntersectionType bucket) were never compared, so 'Foo'|('Foo'&hasMethod(m1)) never collapsed to 'Foo'. Each method_exists() check on a loop-invariant constant class-string doubled the union. 2.2.17 builds the same 2^N union; 2.3 only spends more per merge. The same happens with property_exists(), is_a(), is_subclass_of() and is_callable([$class, $m]).

What was wrong with #6863

#6863 moved these intersections out of the buckets into the general path. There they hit the "generic scalar" shortcut ($hasGenericScalarTypes). The shortcut treats any type whose isInteger()/isBoolean()/isFloat() is yes as plain int/bool/float and deletes every plain constant of that kind. It does the same for strings when the refinement isn't an accessory type. The results are unsound unions and false positives that were not reported before #6863:

/** @template T @param T $x */
function f($x): int { $y = $x === 1 ? $x : 2; if ($y === 2) { return 0; } return $y; }
// Strict comparison using === between 1&T and 2 will always evaluate to false.
  • T of string: $s === 'a' ? $s : 'b' loses 'b', so match reports Match arm comparison between *NEVER* and 'b' is always false.
  • T of bool: === between T of bool&true and false will always evaluate to false.

This matters for future nominal types too. 5&user-id | 7 would lose the 7.

Refined constants also went through the O(n²) pairwise loop. A union of 1000 'aN'&T (a literal list narrowed by in_array() on a template string) went from 2.0 s to 43.7 s.

This change

The intersections stay in their bucket. Before the bucket is compared against the other members, reduceRefinedConstantScalars() drops each one when the plain constant of the same value is present, or when a less refined intersection of the same value contains it. Different values can never overlap, so only same-value refinements are compared, the cost stays linear, and nothing reaches the generic-scalar shortcut. The same change is in the native mirror turbo-ext/src/TypeCombinator.cpp. The instanceof IntersectionType baseline count goes back to 7.

Case Before #6863 #6863 This PR
Issue repro, 15 elements hangs 1.2 s, 'Foo' 1.2 s, 'Foo' (Turbo 1.0 s)
1000 refined literals in one union 2.0 s 43.7 s, wrong type 2.0 s (Turbo 1.3 s)
1&T | 2, string and bool variants correct constant dropped correct

Tests

I also probed 23 loop-invariant narrowing variants at 12–16 elements: union class-strings, class-string<Foo>, objects, $this, static::class, isset()/array_key_exists() on general and shaped arrays, instanceof loops, templates, collecting into arrays, concatenation, and nesting. None is still exponential.

Second commit: the older T of int | 5 bug

The same shortcut already made T of int | 5 collapse to T of int (likewise T of bool | true and T of float | 1.5). 2.2.17 has this too. Any member whose isInteger()/isBoolean()/isFloat() was yes was treated as the whole scalar. Now the member must also be a supertype of the whole scalar ($type->isSuperTypeOf(new IntegerType())->yes(), likewise for bool and float). T of int and 1&T no longer qualify, while int, bool and float still take the fast path. This is covered by nsrt/union-template-scalar-constant.php, which fails without the fix. The same change is in the native mirror.

The prefixed differential test in turbo-ext/tests/initializer-escape.php had been passing only because of this bug. It computes $a - 3 from a native PHPStanTurbo\IntegerType and the PHP twin's constant 3. The shortcut used to swallow the 3. Now the union correctly keeps it, because a native int is not a supertype of the twin's 3. That union mixes both class families, and the prefix doesn't allow that, so the result is a TypeError. The test now accepts that error for BinaryOpHandler, the same way it already accepts the ClassConstFetchHandler one, and expects 15 kept callbacks instead of 16. In real activation there is only one class family, so this can't happen there.

This second commit also fixes phpstan/phpstan#6567. $p + 1000 with @template T of int was typed T of int, because the shortcut swallowed the constant part of the result, so returning it as T wasn't reported. ReturnTypeRuleTest::testBug6567 now expects Function add() should return T of int but returns int. It fails without the fix, and it also pins the T of bool and T of float cases from the issue comments.

Closes phpstan/phpstan#6567

/cc @staabm

…scalar buckets of `TypeCombinator::union()`

`'Foo'|('Foo'&hasMethod(m1))` was never collapsed to `'Foo'` because the scalar
buckets are compared only against the non-scalar union members. Repeated
`method_exists()` / `property_exists()` / `is_a()` checks on a loop-invariant
constant class-string grew the union to 2^N members.

Moving these intersections out of the buckets (the previous fix) sent them
through the generic scalar shortcut, which drops every plain constant of the
same kind once any type with isInteger()/isBoolean()/isFloat() yes is present
(`1&T|2` became `1&T`), and made unions of many refined constants quadratic.

The intersections now stay in their bucket. Before the bucket is compared
against the other members, each one is dropped when the plain constant of the
same value is present, or when a less refined intersection of the same value
contains it. Different values are disjoint, so the cost stays linear.

Ported to the native mirror in turbo-ext/src/TypeCombinator.cpp.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1819f-fbb7-7460-b1bd-aab545ccfc0a
…value of that scalar is present

The generic scalar shortcut in `TypeCombinator::union()` removed every
constant of a scalar once any member answered isInteger()/isBoolean()/isFloat()
with yes, so `T of int|5` became `T of int` (and likewise for `T of bool`
and `T of float`). The member now also has to be a supertype of the whole
scalar type.

Ported to the native mirror. The prefixed differential test computed `$a - 3`
over a native int and the PHP twin's constant 3 and only passed because the
shortcut swallowed the twin's 3; the resulting mixed-family union is now
tolerated there like the other prefix-boundary TypeErrors.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1819f-fbb7-7460-b1bd-aab545ccfc0a
@staabm

staabm commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

this PR seem to also fix phpstan/phpstan#6567

@ondrejmirtes
ondrejmirtes merged commit 8134126 into 2.3.x Oct 28, 2026
505 checks passed
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.

Template type bound to integer vs integer ranges

2 participants