Skip to content

Fix #9908: isset condition doesn't refine type#4962

Closed
phpstan-bot wants to merge 5 commits into2.1.xfrom
create-pull-request/patch-6bz19wz
Closed

Fix #9908: isset condition doesn't refine type#4962
phpstan-bot wants to merge 5 commits into2.1.xfrom
create-pull-request/patch-6bz19wz

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

When using isset($a['key']) on a union type array, the falsey branch did not narrow the array variable's type. This caused the original union members to leak through after the if-block, even though they were handled by the truthy branch.

For example:

$a = [];
if (rand() % 2) {
    $a = ['bar' => 'string'];
}
// $a: array{}|array{bar: 'string'}

if (isset($a['bar'])) {
    $a['bar'] = 1;
}
// Expected: array{}|array{bar: 1}
// Actual:   array{}|array{bar: 'string'}|array{bar: 1}

The array{bar: 'string'} type leaked through because the falsey branch of isset for ArrayDimFetch expressions returned empty SpecifiedTypes(), leaving the array variable's type unchanged. When merged with the truthy branch result, the old type persisted.

Changes

  • src/Analyser/TypeSpecifier.php: In the falsey isset handler for non-variable expressions, added narrowing logic for ArrayDimFetch with constant string/integer dims. The logic iterates over union members of the array variable and removes those where the offset is definitely present (hasOffsetValueType().yes()) and the value is definitely non-null (getOffsetValueType().isNull().no()). When the array variable has uncertain existence (maybe certainty), an IssetExpr specification is included to preserve the maybe certainty.
  • tests/PHPStan/Analyser/nsrt/bug-9908.php: New regression test reproducing the original issue.
  • tests/PHPStan/Analyser/nsrt/tagged-unions.php: Updated test expectation at line 58 to reflect the improved narrowing precision — !isset($foo['C']) now correctly excludes the array{A: string, C: 1} member that definitely has key C with a non-null value.
  • phpstan-baseline.neon: Updated instanceof ConstantStringType count for TypeSpecifier.php (4→5).

Root cause

The TypeSpecifier::specifyTypesInCondition method's falsey isset handler had a code path for non-variable ArrayDimFetch expressions (line ~923) that returned empty SpecifiedTypes() when the offset value was not nullable and the isset check result was uncertain. This meant the array variable received no type narrowing in the falsey branch, causing its original type (including members where isset would be true) to persist and leak through during scope merging after the if-block.

The fix carefully avoids two pitfalls:

  1. Using HasOffsetType removal directly (via TypeCombinator::remove) would be too aggressive for optional keys, where tryRemove unsets the offset entirely.
  2. Any type narrowing through SpecifiedTypes sets variable certainty to yes, which would be wrong when the variable might not be defined. The fix uses IssetExpr to restore maybe certainty when needed.

Test

The regression test tests/PHPStan/Analyser/nsrt/bug-9908.php includes:

  • test(): Asserts that after isset($a['bar']) + $a['bar'] = 1, the type is array{}|array{bar: 1} (not including the original array{bar: 'string'})
  • test2(): Verifies the same code passes when used as an argument to a method expecting array{bar?: int}

Fixes phpstan/phpstan#9908

phpstan-bot and others added 5 commits February 16, 2026 20:53
- In the falsey branch of isset($a['key']), narrow the array variable by removing union members where the key is definitely present with a non-null value
- Preserve variable certainty when the array variable might not be defined (using IssetExpr to restore maybe certainty)
- Update tagged-unions test expectation to reflect improved narrowing precision
- New regression test in tests/PHPStan/Analyser/nsrt/bug-9908.php

Closes phpstan/phpstan#9908
if (!$varType instanceof MixedType) {
$dimType = $scope->getType($issetExpr->dim);

if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we have to explicitely check those instanceof.
I think it could work for every dimType.

but this seems to be the same strategy than

if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) {

Copy link
Contributor

Choose a reason for hiding this comment

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

at the quoted TypeSpecifier location we need the
if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) {

because HasOffsetType constructor types

$typesToRemove = [];
foreach ($types as $innerType) {
$hasOffset = $innerType->hasOffsetValueType($dimType);
if (!$hasOffset->yes() || !$innerType->getOffsetValueType($dimType)->isNull()->no()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

It miss some case with array{foo?: bool} or array{foo: string|null}

@VincentLanglet
Copy link
Contributor

Cherry picked in #4983

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.

3 participants