Skip to content

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

Open
zonuexe wants to merge 3 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 3 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
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.

@zonuexe
zonuexe force-pushed the fix-islist-optional-keys branch from 702067f to 25d81d1 Compare July 20, 2026 06:07
@zonuexe
zonuexe marked this pull request as draft August 6, 2026 01:59
@zonuexe
zonuexe force-pushed the fix-islist-optional-keys branch 2 times, most recently from e3e6293 to 291f84b Compare August 6, 2026 02:43
@zonuexe
zonuexe marked this pull request as ready for review August 6, 2026 02:48
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

Comment thread src/Type/Constant/ConstantArrayType.php
@zonuexe
zonuexe force-pushed the fix-islist-optional-keys branch 3 times, most recently from 91450e0 to 501c39d Compare August 7, 2026 02:11
@staabm
staabm force-pushed the fix-islist-optional-keys branch from 8928a85 to d9f6883 Compare August 7, 2026 08:19
Comment on lines +1660 to +1673
// A sealed shape with an optional key past the gap at 1 (2? here): key 2 can
// never appear in a list, so makeList() drops it and projects to the prefix.
BleedingEdgeToggle::withBleedingEdge(true, function (): void {
$array = $this->buildShape([[0, new IntegerType(), false], [2, new StringType(), true]]);
$this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isList()->describe());
$this->assertSame('array{int}', $array->makeList()->describe(VerbosityLevel::precise()));
});

// Unsealed: extras may fill the gap, so key 2 is reachable and every key is kept.
BleedingEdgeToggle::withBleedingEdge(false, function (): void {
$array = $this->buildShape([[0, new IntegerType(), false], [2, new StringType(), true]]);
$this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isList()->describe());
$this->assertSame('array{0: int, 2?: string}', $array->makeList()->describe(VerbosityLevel::precise()));
});

@staabm staabm Aug 7, 2026

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.

can we use a data-provider to ease reading the test? atm its hard to see where the diff is between with/without bleeding edge.

same in the other test

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.

Done. Both tests are now data providers. testMakeListProjectsSealedShapeButKeepsUnsealedKeys has one row per sealedness: the input shape is the same, only the expected makeList() projection differs. testMergeWithRecomputesListnessOfSealedShape has one named row per {bleeding edge, shapes, expected isList}, so you can compare the with and without cases row by row. The test method applies the toggle itself, because data providers run before the container enables bleeding edge. I re-verified by manual mutation that the rows kill the mutants of the $naiveIsList->or(infer…) combinator in both mergeWith() and legacyMergeWith().


public function testMergeWithTreatsNumericStringKeyAsIntWhenRecomputingListness(): void
{
BleedingEdgeToggle::withBleedingEdge(true, function (): void {

@staabm staabm Aug 7, 2026

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 need a BleedingEdgeToggle::withBleedingEdge(false,...) variant of this test?

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.

Yes, added. inferIsListFromShape() has two call sites: mergeWith() (bleeding edge, sealed by the explicit-never unsealed marker) and legacyMergeWith() (unsealed === null). The test is now a data provider with both variants. The new withBleedingEdge(false) row goes through legacyMergeWith() and fails with Maybe instead of Yes without the toArrayKey() normalization, so it covers the second call site.

zonuexe added 3 commits August 7, 2026 11:06
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
@staabm
staabm force-pushed the fix-islist-optional-keys branch from 01f9c5a to dc9a7e6 Compare August 7, 2026 09:06

@staabm staabm 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.

I like the new data-provider tests a lot more. thank you

@staabm
staabm requested a review from ondrejmirtes August 7, 2026 09:10
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

3 participants