Skip to content

isset() on a nested array offset narrows the falsey branch to never, leaking the intermediate offset's existence (regression in 2.1.40) #15005

Description

@momala454

Bug report

isset() on a nested array offset makes PHPStan treat the intermediate offset as definitely-existing for the rest of the enclosing scope, instead of only inside the check's true branch.

After isset($r['K']['Port']), the type of $r['K'] loses its "may not exist" — even though the statement tells us nothing about $r['K'] once the check is over (if $r['K'] does not exist, isset() is simply false and the offset still does not exist).

This is directly observable with dumpType, and it already produces a false positive on a plain level: 10 run.

Code snippet that reproduces the problem

https://phpstan.org/r/9648cca2-79d8-418e-8fd7-106da24a9ade

<?php

declare(strict_types=1);

/** @param array<string, array{Port: int, Secure: string|null}> $r */
function nestedIssetLeak(array $r): void
{
    \PHPStan\dumpType($r['K'] ?? null);   // array{Port: int, Secure: string|null}|null   <- correct

    $port = isset($r['K']['Port']) ? $r['K']['Port'] : null;

    \PHPStan\dumpType($r['K'] ?? null);   // array{Port: int, Secure: string|null}        <- leaked, |null dropped

    $secure = $r['K']['Secure'] ?? null;

    echo $port, $secure;
}

/** @param array<string, array{Port: int, Secure: string|null}> $r */
function alsoAfterPlainIf(array $r): void
{
    if (isset($r['K']['Port'])) {
        echo $r['K']['Port'];
    }

    $secure = $r['K']['Secure'] ?? null;

    echo $secure;
}

/** @param array<string, array{Port: int, Secure: string|null}> $r */
function notWithCoalesce(array $r): void
{
    $port = $r['K']['Port'] ?? null;      // `??` instead of isset(): no leak
    $secure = $r['K']['Secure'] ?? null;  // not reported
    echo $port, $secure;
}

/** @param array<string, string|null> $r */
function notWithSingleLevel(array $r): void
{
    $port = isset($r['K']) ? $r['K'] : null;  // single-level offset: no leak
    $secure = $r['K'] ?? null;                // not reported
    echo $port, $secure;
}

Expected output

Line 12 should dump array{Port: int, Secure: string|null}|null, same as line 8, and no error should be reported.

Actual output

level: 10, no bleeding edge, PHPStan 2.2.6, PHP 8.5.5:

final.php:8:Dumped type: array{Port: int, Secure: string|null}|null [identifier=phpstan.dumpType]
final.php:12:Dumped type: array{Port: int, Secure: string|null} [identifier=phpstan.dumpType]
final.php:12:Offset 'K' on non-empty-array<string, array{Port: int, Secure: string|null}> on left side of ?? always exists and is not nullable. [identifier=nullCoalesce.offset]

With bleedingEdge.neon the same leak additionally trips the newer nullCoalesce.unnecessary rule twice:

final.php:14:Coalesce operator ?? is unnecessary because the left side is always set and the right side is null. [identifier=nullCoalesce.unnecessary]
final.php:26:Coalesce operator ?? is unnecessary because the left side is always set and the right side is null. [identifier=nullCoalesce.unnecessary]

Regression: introduced in 2.1.40

Bisected with the same snippet (PHP 8.5.5, level: 10, no bleeding edge), looking at the line 12 dumpType:

Version dumpType($r['K'] ?? null) after the isset
1.6.0 – 1.12.0 (level 9) array{...}|null
2.0.0, 2.1.0 … 2.1.39 array{...}|null
2.1.40 array{...}
2.1.41 … 2.2.6 array{...}

Root cause: the falsey branch is narrowed to never

/** @param array<string, array{Port: int, Secure: string|null}> $r */
function branches(array $r): void
{
    if (isset($r['K']['Port'])) {
        \PHPStan\dumpType($r['K'] ?? null);   // expected: array{...}
    } else {
        \PHPStan\dumpType($r['K'] ?? null);   // expected: array{...}|null
    }
    \PHPStan\dumpType($r['K'] ?? null);       // expected: array{...}|null
}
2.1.39   L9: array{Port: int, Secure: string|null}
         L11: array{Port: int, Secure: string|null}|null
         L13: array{Port: int, Secure: string|null}|null

2.1.40   L9: array{Port: int, Secure: string|null}
         L11: *NEVER*
         L13: array{Port: int, Secure: string|null}

In the else branch, $r['K'] is narrowed to *NEVER* — PHPStan believes it is impossible for isset($r['K']['Port']) to be false. Merging the true branch (array{...}) with a never falsey branch then yields array{...}, which is how the |null disappears after the statement.

Reachability is not affected — an else/early-return after such an isset() is not reported as dead code — so the symptom stays confined to types.

The likely culprit in 2.1.40 is "Fix isset() falsey branch not narrowing array type for dim fetches" (phpstan-src#4983, referencing #9908, #10544, #8724, #5128, #12401): it seems to over-narrow the falsey branch to never when the intermediate offset may itself be missing.

Practical impact: the nullCoalesce.unnecessary rule added since 2.2.5 turned this latent wrong type into ~120 false positives on a previously green codebase after a patch upgrade — every $a['x']['y'] ?? null that happens to sit after an isset($a['x'][...]) earlier in the same argument list.

Conditions

Variant Leaks
isset($r['K']['Port']) in a ternary yes
isset($r['K']['Port']) in a plain if yes
$r['K']['Port'] ?? null instead of isset() no
isset($r['K']) — single level no

The nested offset is required; the leak concerns the intermediate dimension only.

Possibly related

Did PHPStan help you today? Did it make you happy in any way?

Yes — the very run that surfaced this also caught a genuine bug, where a @param array{...} shape declared a key as required while one caller built its SELECT without that column.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions