Skip to content

Add null to list-destructured variable types when array offset existence is uncertain#5758

Closed
phpstan-bot wants to merge 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-jyqfagr
Closed

Add null to list-destructured variable types when array offset existence is uncertain#5758
phpstan-bot wants to merge 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-jyqfagr

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

After list($a, $b) = explode('-', $input) or [$a, $b] = $someArray, PHPStan incorrectly inferred that all destructured variables had the array's value type without considering that the array might not have enough elements. This caused false positives like "left side of ?? always exists and is not nullable" when using $b ?? 'default'.

This PR makes GetOffsetValueTypeExprHandler check whether the array offset is guaranteed to exist. When it's not, null is added to the variable's type, matching PHP's runtime behavior where missing array keys produce null.

Changes

  • src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php: In resolveType(), check hasOffsetValueType() on the source type. When the source could be an array and the offset isn't guaranteed to exist, union the result with NullType. String types are excluded since PHP returns empty string (not null) for missing string offsets.
  • src/Node/CollectedDataNode.php: Narrowed return type annotation of get() from list<TValue> to non-empty-list<TValue> — the lists are always non-empty by construction (entries are only created when data exists), and this prevents false nullable types when destructuring collected data.
  • src/Reflection/ConstructorsHelper.php: Added null guard for $methodName after explode('::', ...) destructuring — PHPStan now correctly identifies this as potentially null.
  • Test files: Updated assertions in array-destructuring.php, bug-8127.php, collected-data.php, CallMethodsRuleTest.php, ReflectionProviderGoldenTest.php, and TypeInferenceTestCaseTest.php to match the corrected types. Added null guards in test code where destructured values could be null.

Root cause

GetOffsetValueTypeExprHandler::resolveType() called getOffsetValueType() directly without checking hasOffsetValueType(). The getOffsetValueType() method returns the type assuming the key exists, but for generic array types like list<string> or array<int, string>, the key might not exist. PHP assigns null to variables when a list() destructuring target doesn't have the corresponding array key.

The fix is scoped to array types only (isArray()->no() check) because PHP's behavior differs for strings: missing string offsets return empty string, not null.

Smart handling of known-size arrays: For non-empty-list<string>, PHPStan already knows index 0 is guaranteed to exist (hasOffsetValueType(0) returns yes) thanks to IntersectionType::doHasOffsetValueType() which checks isIterableAtLeastOnce(). So [$a, $b] = explode('-', $input) correctly gives $a type string (guaranteed) and $b type string|null (might not exist).

Test

  • tests/PHPStan/Analyser/nsrt/bug-10854.php: Type inference tests covering list() and [] destructuring from explode(), list<string>, array<int, string>, non-empty-list<string>, constant arrays, and arrays with optional keys. Also tests null coalescing after list assignment.
  • tests/PHPStan/Rules/Variables/data/bug-10854.php + NullCoalesceRuleTest::testBug10854(): NullCoalesceRule test verifying that ?? is not flagged for variables that could be null after list destructuring, but IS correctly flagged for variables guaranteed to exist (index 0 on non-empty-list, required key on shaped array). Also covers ??= and isset() variants.

Fixes phpstan/phpstan#10854

…tence is uncertain

- In `GetOffsetValueTypeExprHandler::resolveType()`, check `hasOffsetValueType()` before returning the offset value type. When the source type could be an array (`isArray()` is not `no`) and the offset existence is not guaranteed (`hasOffsetValueType()` is not `yes`), union the result with `NullType` since PHP assigns `null` to variables when the array key does not exist during list() destructuring.
- Only apply the null union for array types — string offset access returns empty string (not null) for missing offsets, so string destructuring is excluded via the `isArray()->no()` check.
- Update `CollectedDataNode::get()` return type annotation from `list<TValue>` to `non-empty-list<TValue>` since collected data lists are always non-empty by construction.
- Fix PHPStan's own code in `ConstructorsHelper` and test files to handle the newly-detected nullable types from list destructuring.
- Update existing test expectations in `array-destructuring.php` to reflect correct nullable types for generic array destructuring.
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.

While this is technically correct, this creates inconsistency with

[$a, $b] = explode(',', $string);

and

$b = explode(',', $string);

Any suggestion ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The working tree is clean - there are no code changes to make here. The reviewer asked "Any suggestion?" which is a design discussion, not a change request. I investigated applying the same null logic to direct array access (ArrayDimFetchHandler) and confirmed it's not viable in this PR due to cascading effects (52 test failures from null leaking into internal type resolution for unset, nested access, etc.).

Summary: The PR is correct as-is. The inconsistency between list destructuring (adds null) and direct array access (doesn't add null) exists because:

  • GetOffsetValueTypeExprHandler is only used for list-destructuring variable creation
  • ArrayDimFetchHandler::resolveType() is used by internal PHPStan operations (unset type computation, nested access resolution) where adding null causes cascading false positives
  • Direct array access to uncertain offsets is already caught by NonexistentOffsetInArrayDimFetchRule

Making both consistent would require splitting resolveType() into "internal" and "user-facing" variants - a separate architectural effort.

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.

2 participants