Skip to content

Normalize array keys through toArrayKey() in array_fill_keys and array_combine return types - #6078

Open
phpstan-bot wants to merge 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-ykjolgr
Open

phpstan-bot wants to merge 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-ykjolgr

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Documenting an array key as int|decimal-int-string normalizes to int, but array_fill_keys() did not perform this normalization on its result, producing array<int|decimal-int-string, ...>. Assigning that to an array<int, ...> property triggered a false positive. The fix runs the computed key type through Type::toArrayKey(), which coerces integer-like string keys to int exactly like PHP does at runtime.

Changes

  • src/Type/ArrayType.phpfillKeysArray() now returns new ArrayType($keyType->toArrayKey(), $valueType) in both branches, so the resulting key type is coerced the way PHP coerces array keys (this matches flipArray(), which already used toArrayKey()).
  • src/Type/MixedType.phpfillKeysArray() normalizes the key type through toArrayKey() as well, so array_fill_keys() on a mixed array produces an int|string key instead of mixed.
  • src/Type/Php/ArrayCombineHelper.php — the non-constant array_combine() path had the same missing normalization; the key type is now passed through toArrayKey().
  • Updated test expectations in array-fill-keys.php, array-fill-keys-php8.php and array-combine-php8.php that asserted the previous, un-normalized keys.
  • Added tests/PHPStan/Analyser/nsrt/bug-14980.php.

Root cause

array_fill_keys() (and array_combine(), and array_fill_keys() on mixed) use the input array's values as keys. PHP coerces those values with the usual array-key rules (decimal-int-strings become int, floats/bools/objects are stringified first, then re-coerced). PHPStan's Type::toArrayKey() models exactly this coercion, and array_flip()/array_count_values()/array_column() already used it — but the array_fill_keys and non-constant array_combine paths built the key type with toString() (or returned it verbatim) and never applied toArrayKey(). As a result the refinement (decimal-int-string, plain mixed, un-truncated floats, bool) leaked into the key type. The pattern is "value-as-key array functions must normalize the key through toArrayKey()"; every affected location now does.

Test

  • bug-14980.php asserts array_fill_keys() on list<int|decimal-int-string> yields array<int, true> (the reported case), plus the numeric-string, mixed-array and array_combine analogues.
  • Existing array-fill-keys / array-combine fixtures were updated to the now-correct normalized keys (float keys include int, bool keys collapse to int|string, decimal-int-string keys collapse to int).

Fixes phpstan/phpstan#14980
Closes #6077

…`array_combine` return types

- `ArrayType::fillKeysArray()` now passes the resulting key type through `toArrayKey()`, so `decimal-int-string` (and other integer-like string keys) collapse to `int`, matching how PHP coerces array keys. This mirrors what `ArrayType::flipArray()` already does.
- `MixedType::fillKeysArray()` normalizes the key type the same way, so `array_fill_keys` on a `mixed` array no longer yields a `mixed` key type (array keys can only be `int|string`).
- `ArrayCombineHelper` (the non-constant path of `array_combine`) had the identical missing normalization and is fixed the same way.
- Probed the other value-as-key functions: `array_flip`, `array_count_values` and `array_column` already normalize via `toArrayKey()` and were correct; no change needed there.
- Updated existing `array-fill-keys` / `array-combine` expectations that encoded the un-normalized keys (float keys now include `int`, `bool` keys collapse to `int|string`, decimal-int-string keys collapse to `int`).

@VincentLanglet VincentLanglet left a comment

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.

Update array-fill-keys-php7.php too

@VincentLanglet VincentLanglet self-assigned this Sep 19, 2026
Mirrors the array-fill-keys-php8.php change: after routing the key type
through toArrayKey(), array_fill_keys() on a plain array yields
array<int|string, 'b'> instead of array<mixed, 'b'>.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done — pushed as b5ffd0d42.

Change: tests/PHPStan/Analyser/nsrt/array-fill-keys-php7.php:10array<mixed, 'b'>array<'b'>, mirroring the php8 sibling. The mixed item type now goes through toArrayKey() and becomes int|string, which prints as array<'b'>.

I also checked array-combine-php7.php: it needs no change. All its assertions except withNonEmptyArray go through the constant-array path (untouched by this PR), and withNonEmptyArray's keys are 'foo'|'bar'|'baz' — non-numeric strings that toArrayKey() leaves alone.

Verification:

  • make tests — 17608 tests, OK (97 skipped)
  • make phpstan — no errors

One caveat: the php7 file carries // lint < 8.0, so TypeInferenceTestCase::isFileLintSkipped() excludes it from the data provider on this runtime (PHP 8.4). I could not execute that assertion locally — CI's PHP 7.x job is what will actually check it.

$b = ['apple', 'banana'];
}

assertType("non-empty-array<''|'1'|'2', 'apple'|'avocado'|'banana'>", array_combine($a, $b));

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.

Do we have more tests in the codebase with constant int-strings in array keys?

If so, these would need a similar fix?

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.

Pushed as c03c891f7.

Answer to the review question

No other tests need a fix. I audited this two ways:

1. Mechanical sweep of every test expectation. I wrote a parser that extracts array<…> / non-empty-array<…> / iterable<…> generics from all test files, splits the top-level type arguments, and flags any key position containing a quoted decimal-int string — plus a grep for the constant-array-shape form (array{'1': …}). Validated it against the pre-fix tree: it found exactly the 2 lines this PR already changed (array-combine-php8.php:113 and :126). On the current tree: 0 hits. So those two were the only affected expectations in the repo.

2. Empirical probe of the source paths (in case a path had no test at all). I dumped types for every array function that turns values into keys, plus the other ways an int-string key can enter a type, with both constant '1'|'2' and non-constant numeric-string inputs. All already normalize:

path result
array_fill_keys, array_combine, array_flip, array_count_values ✅ (this PR + existing toArrayKey())
array_column (index_key) ✅ via castToArrayKeyType()
iterator_to_array ✅ explicit toArrayKey()
key-preserving fns (array_merge/_replace/_filter/_slice/_chunk/_unique/_reverse/_diff_key/_pad, +) ✅ keys inherited already-normalized
PHPDoc array{'1': string}, array<'1'|'2', string>, unsealed tails TypeNodeResolver
literals, const, class constants, foreach key, array_key_first/last ConstantArrayTypeBuilder::setOffsetValueType() normalizes unconditionally

The one real gap I found is ConstantArrayTypeBuilder::makeUnsealed(), which does not normalize — callers must. I checked all 30 call sites; every value-derived one (ArrayCombineHelper:82, ConstantArrayType::fillKeysArray/flipArray) normalizes, the rest propagate already-normalized keys.

Added coverage

Since the sweep showed the constant-int-string shapes were only covered incidentally, I pinned them explicitly. Verified they fail on the pre-fix tree for the right reason (array<'1'|'2', 'b'>array<1|2, 'b'>), so they're genuine guards, not just green tests. array_flip had no int-string coverage at all despite ArrayType::flipArray() already normalizing — added it so the sibling path is locked in too.

make tests: 17608 tests OK (97 skipped). make phpstan: no errors.

Separate bug found, deliberately not in this PR

array_flip() ignores float and bool values at runtime (warns, skips), but PHPStan models them as keys:

array_flip([1.5])   // runtime: []   PHPStan: array{1: 0}
array_flip([true])  // runtime: []   PHPStan: array{1: 0}

array-count-values.php already documents and handles exactly this (array{} for [true, 1.0, false, 0.0, null]); flipArray() never got the equivalent. It's a different defect from key normalization, so I left this PR focused — happy to open it separately.

…p` and `array_combine`

The normalized-key expectations added so far only exercised
`numeric-string` / `decimal-int-string` item types. Pin the
constant-int-string shapes too, across the constant-array, general-array
and unsealed paths:

- `array_fill_keys()` / `array_combine()` on `list<'1'|'2'>` regress to
  `array<'1'|'2', ...>` without the `toArrayKey()` calls, so these are
  real guards for that fix.
- `array_flip()` had no int-string coverage at all even though
  `ArrayType::flipArray()` already normalized; the assertions lock in the
  sibling path.

Co-Authored-By: Claude Opus 5 <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.

array_fill_keys does not convert decimal-int-string to int

3 participants