Skip to content

Narrow the value of an optional array offset on the left of ?? when compared with ===/!== without asserting it exists#6005

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-mzsyb1t
Open

Narrow the value of an optional array offset on the left of ?? when compared with ===/!== without asserting it exists#6005
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-mzsyb1t

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

After an early return like

if (($options['foo'] ?? null) === false) {
    return;
}

PHPStan used to either memorize $options['foo'] as existing and non-nullable (unsound, fixed earlier in #6000) or, after that fix, drop all value narrowing and infer array{foo?: mixed}. The correct type is array{foo?: mixed~false}: the offset may still be absent, but if present it is not false.

This change restores the value narrowing while keeping it sound — the offset's optional status is preserved and its existence is never asserted.

Changes

  • src/Analyser/TypeSpecifier.php
    • createForExpr(): split the coalesce false-context handling. When the compared type removes the default value, the left side is guaranteed to be set and is narrowed directly (unchanged behaviour). When the default survives (the left side may still be absent), the new helper narrows the value without asserting existence.
    • Added narrowCoalesceLeftPreservingExistence(): for an array-offset left side on a safely-overwritable container, it removes the compared type from the offset's value and rebuilds the container type preserving the key's optional status.
    • Added isSafeCoalesceContainerToOverwrite(): only plain variables and property / static-property chains reaching down to a variable are overwritten. Array offsets and dynamic accesses in the chain are rejected, so a nested offset like $a[$x]['k'] is never memorized as existing.
  • src/Type/Constant/ConstantArrayType.php
    • Added replaceOffsetValueTypePreservingOptionality(): replaces an existing offset's value type without promoting an optional key to a required one (contrast with setExistingOffsetValueType()).
  • phpstan-baseline.neon: bumped one instanceof ConstantStringType baselined count (5 → 6) for the new key-matching in ConstantArrayType, consistent with the sibling unsetOffset()/builder code.

Analogous cases handled

  • === false and !== false, in both branches.
  • Any compared constant, not just false (e.g. !== 2 on array{foo?: 1|2|3}array{foo?: 1|3}, === 0 on array{foo?: int}array{foo?: int<min,-1>|int<1,max>}).
  • Variable containers ($options['foo']) and property containers ($this->options['foo'], $c->options['foo']).
  • The default-eliminated case (($options['foo'] ?? null) !== null) still narrows to a required, non-null offset.

Analogous cases deliberately left unchanged (verified sound)

  • Nested offsets ($rows[$i]['foo']) and general (non-constant) arrays: no narrowing, so existence of a preceding offset is not asserted — this keeps the Do not narrow the left side of ?? to non-null when the coalesce context still permits a falsey value #6000 fix intact.
  • Non-offset left sides (a bare variable or property used as the ?? left operand): the coalesce expression itself is already narrowed by the existing machinery and forcing existence there could be unsound for possibly-undefined variables, so they are left as-is.

Root cause

Writing a narrowed value back to an array offset goes through MutatingScope::specifyExpressionType(), which intersects the container with a HasOffsetValueType, asserting the offset exists. For a ?? access that is exactly wrong: the whole expression can still be produced by a missing offset. The previous fix (#6000) avoided the unsoundness by simply not narrowing at all, losing precision.

The fix narrows the offset value through a dedicated path that rebuilds the constant-array shape and keeps the key optional, so precision is restored without asserting existence.

Test

  • tests/PHPStan/Analyser/nsrt/bug-12057.php covers the reported early-return case, both ===/!== branches, union-value narrowing, non-false compared constants, a property-container variant, the default-eliminated case, and — as a guard against regressing Do not narrow the left side of ?? to non-null when the coalesce context still permits a falsey value #6000 — a nested-offset case that must stay array<string, array{foo?: mixed}>.
  • Confirmed the new assertions fail on 2.2.x before the fix and pass after it.
  • Full make tests and make phpstan are green.

Fixes phpstan/phpstan#12057

… compared with `===`/`!==` without asserting it exists

- In `TypeSpecifier::createForExpr()`, when a coalesce is specified in the
  false context and the compared type does not remove the default value
  (so the left side may still be absent), narrow the left side's value via
  the new `narrowCoalesceLeftPreservingExistence()` helper instead of leaving
  it untouched.
- The helper rebuilds the container's constant-array shape with the compared
  type removed from the offset's value while keeping the key optional, so
  `($a['k'] ?? null) !== false` yields `array{k?: mixed~false}` rather than
  `array{k?: mixed}`, and does so for any compared constant (e.g. `!== 2` on
  `1|2|3` yields `1|3`).
- Only variable and property/static-property containers are overwritten;
  nested offsets (`$a[$x]['k']`) are left alone so the outer offset is not
  wrongly memorized as existing (preserves the fix from phpstan#6000).
- Added `ConstantArrayType::replaceOffsetValueTypePreservingOptionality()`
  which replaces an existing offset's value without promoting an optional
  key to a required one (unlike `setExistingOffsetValueType()`).
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.

incorrect type narrowing triggered by ($a ?? null) === false

2 participants