Skip to content

Fix isset() falsey branch not narrowing array type for dim fetches#4983

Open
VincentLanglet wants to merge 2 commits intophpstan:2.1.xfrom
VincentLanglet:fix/9908
Open

Fix isset() falsey branch not narrowing array type for dim fetches#4983
VincentLanglet wants to merge 2 commits intophpstan:2.1.xfrom
VincentLanglet:fix/9908

Conversation

@VincentLanglet
Copy link
Contributor

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 2 commits February 17, 2026 23:37
- 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
$dimType = $scope->getType($issetExpr->dim);

if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) {
$types = $varType instanceof UnionType ? $varType->getTypes() : [$varType];
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: I'm not sure it's useful to do this if it's not an unionType.

Comment on lines +934 to +950
foreach ($types as $innerType) {
$hasOffset = $innerType->hasOffsetValueType($dimType);
if (!$hasOffset->yes() || !$innerType->getOffsetValueType($dimType)->isNull()->no()) {
continue;
}

$typesToRemove[] = $innerType;
}

if ($typesToRemove !== []) {
$typeToRemove = TypeCombinator::union(...$typesToRemove);
$result = $this->create(
$issetExpr->var,
$typeToRemove,
TypeSpecifierContext::createFalse(),
$scope,
)->setRootExpr($expr);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This need to be reworked cf the test

// Could be "array{a: null, c: string}|array{d: string}"
assertType("array{a: string|null, c: string}|array{a?: string, d: string}", $a);

it would be great to refining the constant array without totally removing them

array{a: string|null, c: string} => array{a: null, c: string}
array{a?: string, d: string} => array{c: string}

Do you see an easy way to do such thing @staabm ?

Copy link
Contributor

@staabm staabm Feb 18, 2026

Choose a reason for hiding this comment

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

would it work to iterate over getConstantArrays() instead?

and re-union the results?

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