Skip to content

An optional non-list key makes isList Maybe, not No#6025

Open
zonuexe wants to merge 4 commits into
phpstan:2.2.xfrom
zonuexe:fix-islist-optional-keys
Open

An optional non-list key makes isList Maybe, not No#6025
zonuexe wants to merge 4 commits into
phpstan:2.2.xfrom
zonuexe:fix-islist-optional-keys

Conversation

@zonuexe

@zonuexe zonuexe commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

A ConstantArrayType whose only list-incompatible keys are optional can still be a list when those keys are absent — array{a?: string} admits [], and array_is_list([]) === true. So its isList must be Maybe, not No. Previously any string / negative / gap key forced isList = No regardless of optionality, so array_is_list() was reported as always false on such shapes.

This is the dual of #12725 (there, shapes that also admit non-list values wrongly report isList yes; here, shapes that also admit list values wrongly report no).

Parts:

  • ConstantArrayTypeBuilder — an optional list-incompatible key degrades isList Yes→Maybe (No stays No) via a new markNonListKey(), replacing four unconditional createNo() sites.
  • ArrayType::isSuperTypeOf() — a possibly-empty constant array always admits [], which is a subtype of every array type, so the relationship is at worst Maybe, never a definite No. This lets the ($value is list ? true : false) conditional of array_is_list() resolve to bool for possibly-empty shapes (e.g. array{a?: string}) instead of false.
  • ConstantArrayType::makeList() — now that gap/string optional keys yield isList Maybe, intersecting a sealed such shape with list keeps only the contiguous 0..m prefix (the keys that can actually appear in a list) rather than collapsing to *NEVER*. Unsealed extras may fill the gaps, so there every key is kept.
  • ConstantArrayType::mergeWith() / legacyMergeWith() — merging widens keys present in only one side into optional keys, so the result can admit list realizations neither input did (array{a: 1}|array{b: 2}array{a?: 1, b?: 2} admits []). The list-ness of a sealed merged shape is now recomputed from the shape via inferIsListFromShape() instead of the too-strict $this->isList->and($otherArray->isList); two pure lists still merge into a list. This is the same merge-side fix as Degrade isList to maybe for optional non-list keys in array shapes and shape merges #6026, folded in here — thanks to the parallel analysis there. (The one deliberate difference from Degrade isList to maybe for optional non-list keys in array shapes and shape merges #6026 is makeList() above: this PR projects invalid sealed list-shapes to their valid form, e.g. list{0: string, 1: int, 2?: string, 4?: string}list{0: string, 1: int, 2?: string}, rather than keeping the unreachable key; see the tradeoff note on Degrade isList to maybe for optional non-list keys in array shapes and shape merges #6026.)
/** @param array{a?: string} $a */         // array_is_list($a): false -> bool
/** @param array{0: int, a?: string} $b */ // array_is_list($b): false -> bool

The existing array-shape-list-optional.php expectations change accordingly: an invalid sealed list-shape like list{0: string, 1: int, 2?: string, 4?: string} now resolves to its valid projection array{0: string, 1: int, 2?: string}.

Closes phpstan/phpstan#14938

zonuexe added 2 commits July 9, 2026 02:57
A ConstantArrayType whose only list-incompatible keys are optional can
still be a list when those keys are absent (`array{a?: string}` admits
`[]`), so its isList must be Maybe, not No. Previously any string /
negative / gap key forced No regardless of optionality, which made
`array_is_list()` report "always false" on such shapes.

- ConstantArrayTypeBuilder: an optional list-incompatible key degrades
  isList Yes to Maybe (No stays No) via markNonListKey().
- ArrayType::isSuperTypeOf(): a possibly-empty constant array always
  admits `[]`, a subtype of every array type, so the relationship is at
  worst Maybe — never a definite No. This lets the `($value is list)`
  conditional of array_is_list() resolve to bool for possibly-empty
  shapes instead of false.
- ConstantArrayType::makeList(): now that gap/string optional keys yield
  isList Maybe, intersecting a sealed such shape with list keeps only the
  contiguous 0..m prefix (the keys that can actually appear in a list)
  instead of collapsing to *NEVER*.

Closes phpstan/phpstan#14938
@zonuexe zonuexe force-pushed the fix-islist-optional-keys branch from e27c6d8 to 9fa048d Compare July 8, 2026 18:19
public function narrowing(array $optStr): void
{
if (array_is_list($optStr)) {
assertType('list{}&list', $optStr);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Before this fix array{a?: string} had isList = No, so this branch was *NEVER* (unreachable). With isList now Maybe, the empty-array realisation survives the is list intersection, so the branch is reachable and narrows to the empty list.

} else {
// array_is_list()'s false branch is not narrowed, so `a` stays optional
// here rather than being refined to array{a: string}.
assertType('array{a?: string}', $optStr);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The a? here (rather than array{a: string}) is pre-existing and unrelated to this fix: array_is_list()'s false branch is not refined. It stays a? for sealed shapes too — e.g. even array{0: string, a?: string} keeps a? in the false branch on current stable.

@zonuexe

zonuexe commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Update: I folded the mergeWith() / legacyMergeWith() fix (and the oracle-validated inferIsListFromShape() helper) from #6026 into this PR — that merge-side case (array{a: 1}|array{b: 2} widening to array{a?: 1, b?: 2}, which admits []) was missing here. Credit for that part goes to #6026.

The only remaining difference between the two PRs is the projection of an invalid sealed list-shape in makeList() (drop the unreachable key here vs. keep it in #6026) — see the A/B tradeoff note I left on #6026 (comment).

@zonuexe zonuexe marked this pull request as ready for review July 8, 2026 19:11
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

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_is_list() reported as always false for shapes whose only non-list keys are optional

2 participants