🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

+= with array<string, mixed> discards the keyed array-shape prefix instead of merging it with the open tail #14799

Description

@paulbalandan

Bug report

When an array{...}-shaped value is combined with an array<string, mixed> via the += (union) operator, PHPStan widens the entire result to non-empty-array<string, mixed> (plus hasOffsetValue(...) for the always-present keys) rather than preserving the already-known keyed prefix. The optional keys are dropped, and the value no longer matches a declared return type that uses an open ...<string, mixed> tail.

This breaks the common "build a known prefix, then fold in caller-supplied extras" pattern, where the declared return type is array{knownKey: T, ...<string, mixed>}.

Code snippet that reproduces the problem

https://phpstan.org/r/b7b3d1b4-bbb4-46bc-af95-4f5650e9a9d4

<?php declare(strict_types = 1);

use function PHPStan\dumpType;

/**
 * @param array<string, mixed> $extras
 * @return array{a: string, b: string, c: string, d?: string, ...<string, mixed>}
 */
function build(?string $d, array $extras): array
{
    $out = [];
    $out['a'] = 'foo';
    $out['b'] = 'bar';
    $out['c'] = 'baz';

    if (null !== $d) {
        $out['d'] = $d;
    }

    dumpType($out); // array{a: 'foo', b: 'bar', c: 'baz', d?: string}

    $out += $extras;

    dumpType($out); // non-empty-array<string, mixed>&hasOffsetValue('a', 'foo')&hasOffsetValue('b', 'bar')&hasOffsetValue('c', 'baz')

    return $out;
}

Config: level 10, strict-rules + bleeding-edge, treatPhpDocTypesAsCertain: true.

Expected output

After $out += $extras, the keyed prefix should survive. += keeps left-hand keys and adds only right-hand keys not already present, so the result should stay assignable to the declared return type, e.g.:

array{a: 'foo', b: 'bar', c: 'baz', d?: string, ...<string, mixed>}

and build() should report no errors.

Actual output

Function build() should return array{a: string, b: string, c: string, d?: string, ...<string, mixed>} but returns non-empty-array<string, mixed>.

The second dumpType shows the cause: array{...} += array<string, mixed> collapses to non-empty-array<string, mixed>&hasOffsetValue(...). The hasOffsetValue intersections are kept for the three required keys, but the optional d? and the array-shape structure are lost, so the result no longer matches the declared shape with its open ...<string, mixed> tail.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions