Skip to content

Create cross-kind conditional expression holders in BooleanAnd/BooleanOr type specifier with truthy fallback for isset()#5760

Open
phpstan-bot wants to merge 3 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-49y396c
Open

Create cross-kind conditional expression holders in BooleanAnd/BooleanOr type specifier with truthy fallback for isset()#5760
phpstan-bot wants to merge 3 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-49y396c

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 25, 2026

Summary

After if (isset($data['key']) && !is_string($data['key'])) { throw ...; }, PHPStan did not narrow $data['key'] to string when subsequently accessed via isset() check or ?? operator. The root cause was that the BooleanAnd falsey handler only created conditional expression holders when both left and right arms had the same "kind" of type specification (both sureTypes or both sureNotTypes), but common patterns produce mismatched kinds.

Changes

  • Added processBooleanNotSureWithSureConditionalTypes() in src/Analyser/TypeSpecifier.php — creates conditional holders using conditions from sureNotTypes (left arm) and holders from sureTypes (right arm)
  • Added processBooleanSureWithNotSureConditionalTypes() in src/Analyser/TypeSpecifier.php — creates conditional holders using conditions from sureTypes (left arm) and holders from sureNotTypes (right arm)
  • Both functions are called (with both argument orderings) in both the BooleanAnd falsey handler and the BooleanOr truthy handler
  • Added truthy fallback: when the left/right arm produces empty SpecifiedTypes in falsey context (as isset() does for non-constant array dim fetch), the code recomputes in truthy context and swaps sureTypes/sureNotTypes to derive usable conditions
  • Added allExpressionsTrackable() guard to prevent the truthy fallback from creating incomplete conditions when non-trackable expressions (method calls) are involved
  • Added regression test covering: simple bool condition + is_string/is_int/is_array, !== null condition, instanceof condition, isset() with ?? coalesce, multiple keys, and array_key_exists()

Root cause

The TypeSpecifier creates conditional expression holders in the falsey branch of BooleanAnd (and truthy branch of BooleanOr) to encode relationships like "if A is true, then B has type X". These holders are created by processBooleanSureConditionalTypes (conditions from sureTypes, holders from sureTypes) and processBooleanNotSureConditionalTypes (conditions from sureNotTypes, holders from sureNotTypes).

For $a && !is_string($y) in falsey context:

  • Left arm ($a falsey): produces sureNotTypes {$a → truthy()}
  • Right arm (!is_string($y) falsey = is_string($y) truthy): produces sureTypes {$y → StringType}

Since left has sureNotTypes and right has sureTypes, neither existing function could create the holder "if $a is true, then $y is string". The new cross-kind functions handle this mismatch.

For isset($data['x']) specifically, the falsey context returns empty SpecifiedTypes for non-constant arrays, so even the cross-kind functions had nothing to work with. The truthy fallback addresses this by deriving conditions from the truthy narrowing of isset() (which includes HasOffsetType on the array variable).

Test

tests/PHPStan/Analyser/nsrt/bug-10644.php covers:

  • Original issue: isset($data['subtitle']) && !is_string(...) with ?? coalesce and isset() recheck
  • Simple boolean condition with is_string, is_int, is_array
  • !== null condition with is_string
  • instanceof condition with is_int
  • Multiple isset keys with different type guards
  • array_key_exists() with is_string

Fixes phpstan/phpstan#10644
Fixes phpstan/phpstan#11918
Fixes phpstan/phpstan#3385
Fixes phpstan/phpstan#6202
Fixes phpstan/phpstan#14455

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.

Is there some refacto with processBooleanNotSureConditionalTypes and processBooleanSureConditionalTypes methods ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. The refactoring unifies the four methods (processBooleanSureConditionalTypes, processBooleanNotSureConditionalTypes, processBooleanNotSureWithSureConditionalTypes, processBooleanSureWithNotSureConditionalTypes) into a single processBooleanConditionalTypes method parameterized by two booleans ($conditionsAreSure, $holdersAreSure). This eliminates 190 lines of duplicated code while preserving the exact same call ordering in array_merge at both call sites. All tests pass and make phpstan reports no errors.

@VincentLanglet VincentLanglet self-assigned this May 26, 2026
Comment thread src/Analyser/TypeSpecifier.php Outdated
Comment thread src/Analyser/TypeSpecifier.php Outdated
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-49y396c branch from daeb428 to 6de7bd1 Compare May 27, 2026 12:02
…leanOr` type specifier with truthy fallback for `isset()`

- Add `processBooleanNotSureWithSureConditionalTypes()` which builds conditions
  from sureNotTypes and holders from sureTypes (cross-kind pairing)
- Add `processBooleanSureWithNotSureConditionalTypes()` which builds conditions
  from sureTypes and holders from sureNotTypes (reverse cross-kind pairing)
- Call both new functions (with both argument orderings) in BooleanAnd falsey
  and BooleanOr truthy conditional holder creation
- Add truthy fallback: when left/right falsey types produce empty SpecifiedTypes
  (e.g. isset() on non-constant array dim fetch), recompute in truthy context
  and swap sureTypes/sureNotTypes to derive conditions for holders
- Guard the truthy fallback with `allExpressionsTrackable()` to prevent
  over-narrowing when non-trackable expressions (method calls) are involved
- Fixes patterns like `if ($a && !is_string($y)) { throw; }` followed by
  `if ($a) { /* $y is now string */ }` and
  `if (isset($data['x']) && !is_string($data['x'])) { throw; }` followed by
  `$data['x'] ?? ''` being correctly inferred as string
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-49y396c branch from 6de7bd1 to ed816ea Compare May 27, 2026 12:03
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.

assertType("string", $data['subtitle'] ?? '');
}

function testSimpleBool(bool $a, mixed $y): void
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.

Use mixed as phpdoc type to avoid failure on php 7.4

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. Two commits pushed:

  1. Use PHPDoc mixed type instead of native mixed for PHP 7.4 compatibility — replaced native mixed type hints with @param mixed PHPDoc annotations in bug-10644.php

  2. Add regression tests for issues #11918, Update crate-ci/typos action to v1.24.3 #3385, #6202, #14455 — four new test files:

    • bug-11918.phparray_key_exists + !is_string guard with ?? fallback
    • bug-3385.php — decomposed XOR null checks across multiple && conditions, verifying both variables are narrowed to non-null
    • bug-6202.phpisset + !is_string with ?? '' coalesce
    • bug-14455.phpempty() + type equality condition with conditional narrowing via hasOffset

All tests pass (12168 tests, 79859 assertions) and make phpstan reports no errors.

phpstan-bot and others added 2 commits May 27, 2026 12:36
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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