Skip to content

Infer decimal-int-string when casting int/int<a, b> to string#5794

Open
phpstan-bot wants to merge 4 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-36uy2j5
Open

Infer decimal-int-string when casting int/int<a, b> to string#5794
phpstan-bot wants to merge 4 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-36uy2j5

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When casting an int to string, PHPStan previously inferred string&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 a decimal-int-string. This PR narrows int-to-string casts to decimal-int-string, which is a strict subtype of the previously inferred accessories.

Changes

  • src/Type/IntegerType.phptoString() now returns string&decimal-int-string instead of string&lowercase-string&uppercase-string&numeric-string.
  • src/Type/IntegerRangeType.phptoString() mirrors the same change for both non-finite branches; ranges that exclude 0 additionally keep non-falsy-string. The finite branch is unchanged (it folds to constant strings, which already compute the property exactly).
  • src/Type/IntersectionType.phptoArrayKey() no longer unconditionally coerces a decimal-int-string to int; it now respects ReportUnsafeArrayStringKeyCastingToggle exactly like StringType::toArrayKey() (lenient string key by default, int only under prevent).
  • Probed and found already-correct: (string) bool''|'1' and (string) <constant int> → constant string both already report the exact decimal-int-string-ness via constant strings, so no change was needed. $int . '' and strval($int) automatically benefit because they route through IntegerType::toString().
  • Updated test expectations across the cast / array-key inference suites (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 precise decimal-int-string, and decimal-int-string.php to the corrected lenient default array-key behavior.

Root cause

int → string casts resolve through Type::toString(). IntegerType/IntegerRangeType hard-coded the generic numeric/lowercase/uppercase accessory bundle instead of the now-available AccessoryDecimalIntegerStringType, so they described the cast result less precisely than the type system can.

While wiring this up, the existing IntersectionType::toArrayKey() turned out to coerce decimal-int-string to int unconditionally — unlike StringType::toArrayKey(), which gates that coercion behind ReportUnsafeArrayStringKeyCastingToggle. This inconsistency meant [$plainString => 1] stayed array<string, 1> by default while [$decimalIntString => 1] became array<int, 1>, and made isset($stringKeyedArray[(string) $int]) a false positive again (the exact pattern guarded by regression test bug #4671) once int casts started producing decimal-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-merged ctype_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 . '', and strval($int) all infer the expected decimal-int-string (or constant) types.
  • Existing IssetRuleTest::testBug4671 (no error on isset($strings[(string) $intput]) for array<string, string>) and the ReportUnsafeArrayStringKeyCasting* detect/prevent suites continue to pass, confirming the toArrayKey toggle fix.

Fixes phpstan/phpstan#14753

Comment thread src/Type/IntersectionType.php Outdated
public function toArrayKey(): Type
{
if ($this->isDecimalIntegerString()->yes()) {
if (ReportUnsafeArrayStringKeyCastingToggle::getLevel() !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this case?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Reverted the toggle (always return IntegerType)IssetRuleTest::testBug4671 fails 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) $int produces decimal-int-string, an unconditional coercion to int makes the key never match an array<string, string>.

  2. 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] becomes non-empty-array<int|decimal-int-string, 1> instead of the precise non-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 pass
  • make phpstan — no errors

Committed as 93bd2ff37 and pushed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a unrelated bugfix, which will be handled in phpstan/phpstan#14758

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-stringisset($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 errors
  • make tests — 12572 tests pass (including IssetRuleTest::testBug4671 and the ReportUnsafeArrayStringKeyCasting* detect/prevent/unsafe suites)

Committed as 35466453a and pushed.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dunno if the change in IntersectionType should be validated by ondrej ; this feature is still new to me.

@staabm staabm force-pushed the create-pull-request/patch-36uy2j5 branch from 93bd2ff to 40f003b Compare June 2, 2026 19:29
phpstan-bot and others added 2 commits June 2, 2026 22:47
- `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>
@staabm staabm force-pushed the create-pull-request/patch-36uy2j5 branch from 40f003b to 2b79f6c Compare June 2, 2026 20:47
phpstan-bot and others added 2 commits June 3, 2026 05:21
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>
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.

Support decimal-int-string in string cast

3 participants