Widen the key type of functions that return an array key - #6222
Open
zonuexe wants to merge 4 commits into
Open
Conversation
getIterableKeyType(), getFirstIterableKeyType() and getLastIterableKeyType() each spelled out the same "implicit mixed tail means array-key" normalization. The first/last pair was also missing the reportUnsafeArrayStringKeyCasting cast that getIterableKeyType() right next to them already applied. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fillKeysArray() on a general array only called toString(), so array_fill_keys() disagreed with array_flip() right next to it: `list<decimal-int-string>` gave `array<decimal-int-string, …>` instead of `array<int, …>`, and `list<float>` kept a numeric-string key instead of the int PHP actually stores. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
zonuexe
force-pushed
the
fix/issue-15073
branch
3 times, most recently
from
August 13, 2026 21:09
96f72e6 to
1fd8041
Compare
PHP casts a decimal-integer string array key ("123") to int, so
`array_key_first([$string => null])` is not necessarily a string. PHPStan
inferred `string` for it and reported `is_int()` on the result as always false.
UnsafeArrayStringKeyCastingTraverser already modelled that cast, but only for
the key type an array *has* — a type that also decides how the array describes
itself and what it accepts, which is why only
`reportUnsafeArrayStringKeyCasting: detect` widens it. Widening it with the
toggle off turns `array<string, X>` into `array<X>` everywhere.
castReadKeyType() is the second entry point, for a key that leaves the array as
a value of its own. With the toggle off it widens `string` to the benevolent
`(int|string)`, so neither branch reports anything; `detect` and `prevent` keep
the types they have today. unionWithReadKeyType() adds the `null` an empty array
gives back without losing the benevolence on the way.
The remaining accessors follow in the next commit.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PHP casts a decimal-integer string array key ("123") to int, so
`array_key_first([$string => null])` is not necessarily a string. PHPStan
inferred `string` for it and reported `is_int()` on the result as always false.
array_key_first() and array_key_last() went first; call castReadKeyType() from
the rest of the accessors that hand a key back as a value of its own:
array_keys(), key(), array_find_key(), array_search() and the values of
array_flip().
`foreach` keys stay unwidened with the toggle off: the key usually goes straight
back into another array, and a benevolent `(int|string)` key collapses that
array to `array<mixed, …>`. `detect` remains the level with accurate `foreach`
keys.
The rename in OptimizedDirectorySourceLocatorFactory is fallout: with the wider
`array_keys()` type PHPStan now proves `$file` defined inside
`if ($findInFiles !== [])`, which is correct and makes strict-rules flag the
reuse.
The `@var int|string` workarounds over `key([$string => null])` in
ArgumentsNormalizer and ConstantStringType are what this bug looks like from the
inside; both are redundant now and go away with their baseline entries. The two
calls also read better as array_key_first(), which says what they are after.
Closes phpstan/phpstan#15073
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
zonuexe
force-pushed
the
fix/issue-15073
branch
from
August 14, 2026 04:08
1fd8041 to
5c531b3
Compare
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.
Closes phpstan/phpstan#15073
Continues the
numeric-string/decimal-int-stringconsistency work of #3326, phpstan/phpstan#6847 and #5328.Problem
PHP casts a decimal-integer string array key (
"123") toint, so a key read out of an arraywith a
stringkey type can be anint:array_key_last(),array_keys()[0],key(),array_find_key(),array_search()and the values ofarray_flip()have the same problem.Change
UnsafeArrayStringKeyCastingTraverseralready models this cast for the key type an array has. That type also drivesdescribe(),accepts()and type subtraction, which is why onlyreportUnsafeArrayStringKeyCasting: detectwidens it. I tried widening it with the toggle off:array<string, X>turns intoarray<X>everywhere and 113 tests fail, includingmixed~array<string, mixed>losing every array.So this adds a second entry point, for keys that leave the array as a value:
detectcastKeyType()int|non-decimal-int-stringcastReadKeyType()(int|string)unionWithReadKeyType()null/falsefor an empty arrayThe benevolent union keeps new errors out:
is_int($key)reportsmaybeinstead of "always false", andstrlen($key)still passes.unionWithReadKeyType()exists becauseTypeCombinator::union()drops the benevolence, which would breakstrlen(array_key_first($a) ?? '')on a possibly emptyarray<string, …>.Left alone
foreachkeys, with the toggle off. You write$result[$k] = …right after, and a benevolent(int|string)key turns that array intoarray<mixed, …>. Six nsrt files lose their key type that way. Usedetectfor accurateforeachkeys.array_flip()andarray_fill_keys()build. They keep the rawstring, and reading a key back out of them runs the new cast anyway.array_rand(). Same bug, but its result collapses toint|stringby construction, sostrlen(array_rand($stringKeyed))would start failing. Separate PR.Also in here
The first two commits stand on their own:
fillKeysArray()on a general array only calledtoString(), soarray_fill_keys()disagreed witharray_flip()next to it.getIterableKeyType(),getFirstIterableKeyType()andgetLastIterableKeyType()repeated the same unsealed-tail normalization, and the first/last pair missed thedetectcast.Two more things ride along in the third commit. The
@var int|stringoverkey([$string => null])inArgumentsNormalizerandConstantStringTypecovered for this bug; both are gone, with their baseline entries. The$filerename inOptimizedDirectorySourceLocatorFactoryis fallout: PHPStan now proves$filedefined insideif ($findInFiles !== []), so strict-rules flags the reuse.Tests
nsrt/bug-15073.phpis new and fails on 8 assertions without thesrc/change. Thedetectfixture gets read-key cases. Other expectation updates come in two shapes:int|string|nullto(int|string|null)where the benevolence survives now, andstringto(int|string)where the fix applies.