Infer decimal-int-string when casting int/int<a, b> to string#5794
Infer decimal-int-string when casting int/int<a, b> to string#5794phpstan-bot wants to merge 3 commits into
decimal-int-string when casting int/int<a, b> to string#5794Conversation
| public function toArrayKey(): Type | ||
| { | ||
| if ($this->isDecimalIntegerString()->yes()) { | ||
| if (ReportUnsafeArrayStringKeyCastingToggle::getLevel() !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { |
There was a problem hiding this comment.
I've addressed staabm's review question on IntersectionType::toArrayKey().
What I did
The reviewer asked "why do we need this case?" about the new ReportUnsafeArrayStringKeyCastingToggle gate on the decimal-int-string branch. I investigated empirically rather than assuming, and confirmed the case is genuinely required:
-
Reverted the toggle (always return
IntegerType) →IssetRuleTest::testBug4671fails with a false positive:
Offset decimal-int-string on array<string, string> in isset() does not exist.
This is exactly the UnresolvableTypeHelper: early return type traversal #4671 regression: once(string) $intproducesdecimal-int-string, an unconditional coercion tointmakes the key never match anarray<string, string>. -
Removed the whole decimal block (falling through to the numeric-string branch) → UnresolvableTypeHelper: early return type traversal #4671 passes, but the lenient default loses precision:
[$decimalIntString => 1]becomesnon-empty-array<int|decimal-int-string, 1>instead of the precisenon-empty-array<decimal-int-string, 1>.
So the toggle is the correct design: it mirrors StringType::toArrayKey() (narrow to int only under prevent, keep the precise string key by default) — giving both the #4671 fix and maximum precision.
Since the case is genuinely needed, I answered the reviewer's question in the code by adding an explanatory comment documenting the PHP coercion semantics, the parallel to StringType::toArrayKey(), and the #4671 reference.
Verification
make tests— 12570 tests passmake phpstan— no errors
Committed as 93bd2ff37 and pushed.
There was a problem hiding this comment.
this is a unrelated bugfix, which will be handled in phpstan/phpstan#14758
There was a problem hiding this comment.
Done. Here's how I addressed staabm's review.
Review comment
this is a unrelated bugfix, which will be handled in phpstan/phpstan#14758
The reviewer flagged the ReportUnsafeArrayStringKeyCastingToggle gate in IntersectionType::toArrayKey() as out of scope for this PR — it should be handled in #14758.
What I did
I removed the decimal-int-string special-casing from IntersectionType::toArrayKey() entirely (not just the toggle gate), along with the now-unused import.
Why not a pure revert to the prior return new IntegerType()? That pre-existing unconditional coercion is exactly what causes the #4671 regression once this PR makes (string) $int produce decimal-int-string — isset($stringKeyedArray[(string) $int]) would falsely report the offset can never exist. A pure revert leaves the test suite red.
Instead, by deleting the special case, decimal-int-string now falls through to the existing numeric-string branch (it is a numeric-string), yielding int|decimal-int-string — consistent with every other numeric string and containing a string component, so #4671 stays green. This PR no longer touches the toggle-based precision behavior at all; that's left for #14758.
I updated the one affected expectation in decimal-int-string.php: [$s => 1] for a decimal-int-string key is now non-empty-array<int|decimal-int-string, 1>.
Verification
make phpstan— no errorsmake tests— 12572 tests pass (includingIssetRuleTest::testBug4671and theReportUnsafeArrayStringKeyCasting*detect/prevent/unsafe suites)
Committed as 35466453a and pushed.
VincentLanglet
left a comment
There was a problem hiding this comment.
Dunno if the change in IntersectionType should be validated by ondrej ; this feature is still new to me.
93bd2ff to
40f003b
Compare
- `IntegerType::toString()` now returns `string&decimal-int-string` instead of `string&lowercase-string&uppercase-string&numeric-string`. Casting an int to string always yields the canonical decimal representation (no leading `+`/zeros, no exponent), which is exactly `decimal-int-string` (a subtype of numeric/lowercase/uppercase, so this is strictly more precise). - `IntegerRangeType::toString()` mirrors the same change for the non-finite branches, keeping `non-falsy-string` for ranges that exclude `0`. - `(string) bool` and `(string) constant-int` were already precise (`''|'1'` / constant strings), so no change was needed there. - Fixed an adjacent inconsistency: `IntersectionType::toArrayKey()` coerced `decimal-int-string` to `int` unconditionally, ignoring `ReportUnsafeArrayStringKeyCastingToggle`, while `StringType::toArrayKey()` respects it. This made `[$decimalIntString => 1]` infer `array<int, 1>` by default even though `[$plainString => 1]` stays `array<string, 1>`, and reintroduced the bug phpstan#4671 false positive (`isset($stringKeyedArray[(string) $int])`). The decimal branch now stays a string key unless the toggle is `PREVENT`, matching `StringType`. - Updated `decimal-int-string.php` to reflect the corrected lenient default (`[$s => 1]` is `array<decimal-int-string, 1>`), and refreshed the expected types in the cast/array-key inference tests that now report the more precise `decimal-int-string`.
Explains, in response to review, that the toggle gate mirrors StringType::toArrayKey() and is required to avoid the bug phpstan#4671 false positive (isset on a string-keyed array with a (string) $int offset). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
40f003b to
2b79f6c
Compare
The toArrayKey() coercion of decimal-int-string keys is an unrelated concern tracked separately in phpstan/phpstan#14758, so it is dropped from this PR. Removing the special case lets decimal-int-string fall through to the numeric-string branch (it is a numeric-string), yielding int|decimal-int-string just like any other numeric string. This keeps the bug phpstan#4671 isset regression test green without implementing the toggle-gated precision behavior, which belongs in #14758. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
When casting an
inttostring, PHPStan previously inferredstring&lowercase-string&uppercase-string&numeric-string. Since casting an integer to a string always produces its canonical decimal representation (e.g."0","123","-1"— never"+1","00", or"1.0E+3"), the result is exactly adecimal-int-string. This PR narrows int-to-string casts todecimal-int-string, which is a strict subtype of the previously inferred accessories.Changes
src/Type/IntegerType.php—toString()now returnsstring&decimal-int-stringinstead ofstring&lowercase-string&uppercase-string&numeric-string.src/Type/IntegerRangeType.php—toString()mirrors the same change for both non-finite branches; ranges that exclude0additionally keepnon-falsy-string. The finite branch is unchanged (it folds to constant strings, which already compute the property exactly).src/Type/IntersectionType.php—toArrayKey()no longer unconditionally coerces adecimal-int-stringtoint; it now respectsReportUnsafeArrayStringKeyCastingToggleexactly likeStringType::toArrayKey()(lenientstringkey by default,intonly underprevent).(string) bool→''|'1'and(string) <constant int>→ constant string both already report the exactdecimal-int-string-ness via constant strings, so no change was needed.$int . ''andstrval($int)automatically benefit because they route throughIntegerType::toString().cast-to-numeric-string.php,range-to-string.php,strval.php,array-key-exists.php,key-exists.php,bug-4587.php,bug-7387.php,bug-8635.php,bug-11716.php,bug-12393b.php,bug-14525.php,generics.php,filter-var.php,set-type-type-specifying.php,bug-6301.php) to the more precisedecimal-int-string, anddecimal-int-string.phpto the corrected lenient default array-key behavior.Root cause
int → stringcasts resolve throughType::toString().IntegerType/IntegerRangeTypehard-coded the generic numeric/lowercase/uppercase accessory bundle instead of the now-availableAccessoryDecimalIntegerStringType, so they described the cast result less precisely than the type system can.While wiring this up, the existing
IntersectionType::toArrayKey()turned out to coercedecimal-int-stringtointunconditionally — unlikeStringType::toArrayKey(), which gates that coercion behindReportUnsafeArrayStringKeyCastingToggle. This inconsistency meant[$plainString => 1]stayedarray<string, 1>by default while[$decimalIntString => 1]becamearray<int, 1>, and madeisset($stringKeyedArray[(string) $int])a false positive again (the exact pattern guarded by regression test bug #4671) once int casts started producingdecimal-int-string. Gating the decimal branch on the same toggle restores the lenient default and fixes the regression for both new int-cast results and previously-mergedctype_digit()narrowing.Test
tests/PHPStan/Analyser/nsrt/bug-14753.php— asserts(string) $int, ranged ints (int<500, max>,int<min, -500>,int<0, max>,int<-10, 10>),(string) $bool, constant int/bool casts,$int . '', andstrval($int)all infer the expecteddecimal-int-string(or constant) types.IssetRuleTest::testBug4671(no error onisset($strings[(string) $intput])forarray<string, string>) and theReportUnsafeArrayStringKeyCasting*detect/prevent suites continue to pass, confirming thetoArrayKeytoggle fix.Fixes phpstan/phpstan#14753