Skip to content

Narrow loose comparisons by the values each side can loosely equal - #6938

Open
ondrejmirtes wants to merge 1 commit into
float-range-typefrom
loose-equality-narrowing
Open

ondrejmirtes wants to merge 1 commit into
float-range-typefrom
loose-equality-narrowing

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Stacked on #6937. Review only the last commit.

Adds Type::getPossiblyLooselyEqualType(PhpVersion) and Type::getAlwaysLooselyEqualType(PhpVersion). For the configured PHP version, the first is the set of values $this == $x can be true for, and the second is the set it is always true for. LooseComparisonHelper computes both.

How they are used

  • == narrowing. The true branch intersects each side with the other side's possible set. The false branch removes the other side's always set. This replaces the == 0 and == '' special cases that hedged between PHP 7 and PHP 8.
  • resolveEqualType(). When looseCompare() is undecided, it now answers false when the other side is outside the possible set, and true when it is inside the always set.
  • Loose in_array(). It now narrows the needle too, and makes the haystack non-empty in the true branch.
    • An enum is only compared strictly when the other side cannot be true.
    • Two strings are only compared strictly when they cannot both be numeric.
  • Cast truthiness. (int), (float) and (string) get their own zero and falsy sets, because (int) 'abc', (int) 0.5 and (string) 0 do not follow == 0 and == ''.

mixed stays mixed. When the true branch would turn mixed into a union containing objects such as GMP|Stringable, the subject is left as mixed. This covers explicit, implicit and subtracted mixed, and templates bounded by mixed, for ==, switch and loose in_array(). Enum cases are precise enough, so they still narrow. Every other type still narrows, for example string|bool|null == 'a' gives 'a'|true and object|string == 'a' gives 'a'|GMP|Stringable. Note that mixed == 0 and mixed == '' now stay mixed.

PHP version ranges. Type methods still take one PhpVersion. Callers that have a scope expand Scope::getPhpVersion() with LooseComparisonHelper::getRepresentativePhpVersions(): one version per range that compares alike, split at 8.0 and 8.4. The possible sets are unioned, the always sets are intersected, and looseCompare() is constant only when every version agrees. With a 7.4–8.5 range, 0 == 'abc' is bool and $int == 'abc' narrows to 0. Inside if (PHP_VERSION_ID >= 80000) the PHP 8 rules apply. Scope-less initializer expressions keep the single configured version.

Runtime facts in the sets. These were measured on PHP 7.4, 8.0 and 8.5:

  • Every object equals 1 and true.
  • On PHP 8, only Stringable and GMP objects equal strings. On PHP 7, any object can.
  • Only GMP, BcMath\Number and SimpleXMLElement objects equal other numbers or false.
  • NAN equals only true. INF == 'INF' is true from PHP 8.

Trade-off. Two numeric strings compare as numbers ('1E3' == '1e3'), so a pair of numeric strings is no longer narrowed as if it were ===. Some types get noisier but sound unions as a result, for example in bug-12312.

The Turbo mirrors are in the same commit. Self-analysis is byte-identical with Turbo on and off.

Closes phpstan/phpstan#8224

Type gets getPossiblyLooselyEqualType() and getAlwaysLooselyEqualType(),
which LooseComparisonHelper computes for the configured PHP version: the
values `$this == $x` can be true for, and the values it is always true for.
The true branch of `==` intersects the other side with the first, the false
branch removes the second, and resolveEqualType() answers false or true when
the other side misses the first or lies within the second. This replaces the
`== 0` and `== ''` branches that hedged between PHP 7 and PHP 8.

Objects equal 1 and true; only Stringable and GMP objects equal strings and
only GMP, BcMath\Number and SimpleXMLElement objects equal other numbers or
false. NAN equals only true.

Two numeric strings compare as numbers, so `==` and a loose in_array() no
longer narrow such a pair as `===`, and an enum compared with a possible true
is no longer strict in in_array(). A loose in_array() now narrows the needle
as well. Cast truthiness gets its own value sets instead of `== 0` and
`== ''`, which (int) 'abc', (int) 0.5 and (string) 0 do not follow.

Closes phpstan/phpstan#8224
@ondrejmirtes
ondrejmirtes force-pushed the loose-equality-narrowing branch from ae9fc74 to 2737e8b Compare November 4, 2026 11:22
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