← All results

Discriminated union of array shapes, narrowed through match

Group: Arrays and shapes · Category: phpdoc · File: arrays_shape_discriminant_match.php

A tagged union in PHPDoc: two array shapes sharing a literal-typed type key, each carrying a key the other lacks. Matching on the discriminant is supposed to select one member per arm, which decides three things at once — whether the variant-specific read in each arm is a known offset, whether the two literals exhaust the match, and whether reading the *other* variant's key is caught. A tool that keeps the union unnarrowed gets all three wrong: it doubts the valid reads, wants a default arm, and misses the invalid read. References: - PHPStan: match expression narrowing of constant-string unions - Psalm: match on array shape literal keys

Analyzer results

AnalyzerVersionResultDiagnostics
phan 6.0.7Phan 6.0.7 php-ast version 1.1.3 PHP version used to run Phan: 8.5.9 Pass

No diagnostics reported.

phpstan 2.2.8PHPStan - PHP Static Analysis Tool 2.2.8 Pass reported Lv.3+
arrays_shape_discriminant_match.php:45: Function Conformance\Tests\ArraysShapeDiscriminantMatch\crossedArms() should return string but returns mixed. [identifier=return.type] [reported-from-level=10]
arrays_shape_discriminant_match.php:46: Offset 'cover_url' does not exist on array{type: 'Illust', image_url: non-falsy-string}. [identifier=offsetAccess.notFound] [reported-from-level=3]
arrays_shape_discriminant_match.php:47: Offset 'image_url' does not exist on array{type: 'Novel', cover_url: non-falsy-string}. [identifier=offsetAccess.notFound] [reported-from-level=3]
With strict-rules
arrays_shape_discriminant_match.php:45: Function Conformance\Tests\ArraysShapeDiscriminantMatch\crossedArms() should return string but returns mixed. [identifier=return.type]
arrays_shape_discriminant_match.php:46: Offset 'cover_url' does not exist on array{type: 'Illust', image_url: non-falsy-string}. [identifier=offsetAccess.notFound]
arrays_shape_discriminant_match.php:47: Offset 'image_url' does not exist on array{type: 'Novel', cover_url: non-falsy-string}. [identifier=offsetAccess.notFound]
psalm 6.16.1Psalm 6.16.1@f1f5de594dc76faf8784e02d3dc4716c91c6f6ac
next: 7.0.0-beta19
Fail (pzoom≠)
arrays_shape_discriminant_match.php:26: The declared return type 'string' for Conformance\Tests\ArraysShapeDiscriminantMatch\coverUrl is not nullable, but 'null|string' contains null [InvalidNullableReturnType]
arrays_shape_discriminant_match.php:32: The declared return type 'string' for Conformance\Tests\ArraysShapeDiscriminantMatch\coverUrl is not nullable, but the function returns 'non-falsy-string|null' [NullableReturnStatement]
arrays_shape_discriminant_match.php:33: Possibly undefined array key $work['image_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Illust'|'Novel'} [PossiblyUndefinedArrayOffset]
arrays_shape_discriminant_match.php:34: Possibly undefined array key $work['cover_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Novel'} [PossiblyUndefinedArrayOffset]
arrays_shape_discriminant_match.php:41: The declared return type 'string' for Conformance\Tests\ArraysShapeDiscriminantMatch\crossedArms is not nullable, but 'null|string' contains null [InvalidNullableReturnType]
arrays_shape_discriminant_match.php:45: The declared return type 'string' for Conformance\Tests\ArraysShapeDiscriminantMatch\crossedArms is not nullable, but the function returns 'non-falsy-string|null' [NullableReturnStatement]
arrays_shape_discriminant_match.php:46: Possibly undefined array key $work['cover_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Illust'|'Novel'} [PossiblyUndefinedArrayOffset]
arrays_shape_discriminant_match.php:47: Possibly undefined array key $work['image_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Novel'} [PossiblyUndefinedArrayOffset]
pzoom (Psalm port)
arrays_shape_discriminant_match.php:32: The type non-falsy-string|null does not match the declared return type string [NullableReturnStatement]
arrays_shape_discriminant_match.php:33: Cannot access value using offset value of 'image_url' [PossiblyInvalidArrayOffset]
arrays_shape_discriminant_match.php:33: Possibly undefined array offset [PossiblyUndefinedArrayOffset]
arrays_shape_discriminant_match.php:34: Cannot access value using offset value of 'cover_url' [PossiblyInvalidArrayOffset]
arrays_shape_discriminant_match.php:34: Possibly undefined array offset [PossiblyUndefinedArrayOffset]
arrays_shape_discriminant_match.php:45: The type non-falsy-string|null does not match the declared return type string [NullableReturnStatement]
arrays_shape_discriminant_match.php:46: Cannot access value using offset value of 'cover_url' [PossiblyInvalidArrayOffset]
arrays_shape_discriminant_match.php:46: Possibly undefined array offset [PossiblyUndefinedArrayOffset]
arrays_shape_discriminant_match.php:47: Cannot access value using offset value of 'image_url' [PossiblyInvalidArrayOffset]
arrays_shape_discriminant_match.php:47: Possibly undefined array offset [PossiblyUndefinedArrayOffset]
Expectation diff
Line 26: Unexpected errors ["The declared return type 'string' for Conformance\\Tests\\ArraysShapeDiscriminantMatch\\coverUrl is not nullable, but 'null|string' contains null [InvalidNullableReturnType]"]
Line 32: Unexpected errors ["The declared return type 'string' for Conformance\\Tests\\ArraysShapeDiscriminantMatch\\coverUrl is not nullable, but the function returns 'non-falsy-string|null' [NullableReturnStatement]"]
Line 33: Unexpected errors ["Possibly undefined array key $work['image_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Illust'|'Novel'} [PossiblyUndefinedArrayOffset]"]
Line 34: Unexpected errors ["Possibly undefined array key $work['cover_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Novel'} [PossiblyUndefinedArrayOffset]"]
Line 41: Unexpected errors ["The declared return type 'string' for Conformance\\Tests\\ArraysShapeDiscriminantMatch\\crossedArms is not nullable, but 'null|string' contains null [InvalidNullableReturnType]"]
mago 1.46.0mago 1.46.0 Fail
arrays_shape_discriminant_match.php:32: Function `Conformance\Tests\ArraysShapeDiscriminantMatch\coverUrl` is declared to return `string` but possibly returns a nullable value (inferred as `null|truthy-string`). [nullable-return-statement]
arrays_shape_discriminant_match.php:32: Invalid return type for function `Conformance\Tests\ArraysShapeDiscriminantMatch\coverUrl`: expected `string`, but found `null|truthy-string`. [invalid-return-statement]
arrays_shape_discriminant_match.php:33: Possibly undefined array key accessed on `array{'cover_url': truthy-string, 'type': string('Novel')}|array{'image_url': truthy-string, 'type': string('Illust')}`. [possibly-undefined-string-array-index]
arrays_shape_discriminant_match.php:45: Function `Conformance\Tests\ArraysShapeDiscriminantMatch\crossedArms` is declared to return `string` but possibly returns a nullable value (inferred as `null|truthy-string`). [nullable-return-statement]
arrays_shape_discriminant_match.php:45: Invalid return type for function `Conformance\Tests\ArraysShapeDiscriminantMatch\crossedArms`: expected `string`, but found `null|truthy-string`. [invalid-return-statement]
arrays_shape_discriminant_match.php:46: Possibly undefined array key accessed on `array{'cover_url': truthy-string, 'type': string('Novel')}|array{'image_url': truthy-string, 'type': string('Illust')}`. [possibly-undefined-string-array-index]
arrays_shape_discriminant_match.php:47: Undefined array key 'image_url' accessed on `array{'cover_url': truthy-string, 'type': string('Novel')}`. [undefined-string-array-index]
Expectation diff
Line 32: Unexpected errors ["Function `Conformance\\Tests\\ArraysShapeDiscriminantMatch\\coverUrl` is declared to return `string` but possibly returns a nullable value (inferred as `null|truthy-string`). [nullable-return-statement]","Invalid return type for function `Conformance\\Tests\\ArraysShapeDiscriminantMatch\\coverUrl`: expected `string`, but found `null|truthy-string`. [invalid-return-statement]"]
Line 33: Unexpected errors ["Possibly undefined array key accessed on `array{'cover_url': truthy-string, 'type': string('Novel')}|array{'image_url': truthy-string, 'type': string('Illust')}`. [possibly-undefined-string-array-index]"]
mir 0.70.1mir 0.70.1 Fail
arrays_shape_discriminant_match.php:33: NonExistentArrayOffset: Array offset 'image_url' does not exist [MIR0301]
arrays_shape_discriminant_match.php:34: NonExistentArrayOffset: Array offset 'cover_url' does not exist [MIR0301]
arrays_shape_discriminant_match.php:46: NonExistentArrayOffset: Array offset 'cover_url' does not exist [MIR0301]
arrays_shape_discriminant_match.php:47: NonExistentArrayOffset: Array offset 'image_url' does not exist [MIR0301]
Expectation diff
Line 33: Unexpected errors ["NonExistentArrayOffset: Array offset 'image_url' does not exist [MIR0301]"]
Line 34: Unexpected errors ["NonExistentArrayOffset: Array offset 'cover_url' does not exist [MIR0301]"]
phpantom 0.9.0phpantom_lsp 0.9.0 Pass

No diagnostics reported.

intelephense 1.18.5intelephense 1.18.5 Fail
arrays_shape_discriminant_match.php:32: Expected type 'string'. Found 'mixed'. [P1006]
arrays_shape_discriminant_match.php:45: Expected type 'string'. Found 'mixed'. [P1006]
Expectation diff
Line 32: Unexpected errors ["Expected type 'string'. Found 'mixed'. [P1006]"]
phpy 0.2.0phpy 0.2.0 Pass

No diagnostics reported.

qodana 262.8665.325Qodana 262.8665.325 Fail
arrays_shape_discriminant_match.php:51: Incomplete array according to shape declaration. Missing key(s): cover_url [PhpMissingArrayKeyInspection]
arrays_shape_discriminant_match.php:52: Incomplete array according to shape declaration. Missing key(s): image_url [PhpMissingArrayKeyInspection]
Expectation diff
Line 51: Unexpected errors ["Incomplete array according to shape declaration. Missing key(s): cover_url [PhpMissingArrayKeyInspection]"]
Line 52: Unexpected errors ["Incomplete array according to shape declaration. Missing key(s): image_url [PhpMissingArrayKeyInspection]"]
noverify 0.5.5NoVerify, version 0.5.5: built on: 2025.04.22 14:36:46 OS: r-kuchinskas arm64 Commit: 4774b82f8adc53fbef38f95e97af4b953b4d1529 Fail
arrays_shape_discriminant_match.php:12: Class or interface named \Conformance\Tests\ArraysShapeDiscriminantMatch\non-falsy-string does not exist [undefinedClass]
arrays_shape_discriminant_match.php:12: Class or interface named \Conformance\Tests\ArraysShapeDiscriminantMatch\non-falsy-string does not exist [undefinedClass]
arrays_shape_discriminant_match.php:26: param $work miss matched with phpdoc type <<array{type: 'Illust', image_url: non-falsy-string}|array{type: 'Novel', cover_url: non-falsy-string}>> [funcParamTypeMissMatch]
arrays_shape_discriminant_match.php:39: Class or interface named \Conformance\Tests\ArraysShapeDiscriminantMatch\non-falsy-string does not exist [undefinedClass]
arrays_shape_discriminant_match.php:39: Class or interface named \Conformance\Tests\ArraysShapeDiscriminantMatch\non-falsy-string does not exist [undefinedClass]
arrays_shape_discriminant_match.php:41: param $work miss matched with phpdoc type <<array{type: 'Illust', image_url: non-falsy-string}|array{type: 'Novel', cover_url: non-falsy-string}>> [funcParamTypeMissMatch]
Expectation diff
Line 12: Unexpected errors ["Class or interface named \\Conformance\\Tests\\ArraysShapeDiscriminantMatch\\non-falsy-string does not exist [undefinedClass]","Class or interface named \\Conformance\\Tests\\ArraysShapeDiscriminantMatch\\non-falsy-string does not exist [undefinedClass]"]
Line 26: Unexpected errors ["param $work miss matched with phpdoc type <<array{type: 'Illust', image_url: non-falsy-string}|array{type: 'Novel', cover_url: non-falsy-string}>> [funcParamTypeMissMatch]"]
Line 39: Unexpected errors ["Class or interface named \\Conformance\\Tests\\ArraysShapeDiscriminantMatch\\non-falsy-string does not exist [undefinedClass]","Class or interface named \\Conformance\\Tests\\ArraysShapeDiscriminantMatch\\non-falsy-string does not exist [undefinedClass]"]
Line 41: Unexpected errors ["param $work miss matched with phpdoc type <<array{type: 'Illust', image_url: non-falsy-string}|array{type: 'Novel', cover_url: non-falsy-string}>> [funcParamTypeMissMatch]"]
steins 0.1.5steins 0.1.5 (2026-08-12 revision 39b4cc1) Pass

No diagnostics reported.

phpstan-strict 2.2.8PHPStan - PHP Static Analysis Tool 2.2.8 Pass
arrays_shape_discriminant_match.php:45: Function Conformance\Tests\ArraysShapeDiscriminantMatch\crossedArms() should return string but returns mixed. [identifier=return.type]
arrays_shape_discriminant_match.php:46: Offset 'cover_url' does not exist on array{type: 'Illust', image_url: non-falsy-string}. [identifier=offsetAccess.notFound]
arrays_shape_discriminant_match.php:47: Offset 'image_url' does not exist on array{type: 'Novel', cover_url: non-falsy-string}. [identifier=offsetAccess.notFound]
psalm-next 7.0.0-beta19Psalm 7.0.0-beta19@7e751c06a756fa64dc4c759c09fe4a173afcb433 Fail
arrays_shape_discriminant_match.php:26: The declared return type 'string' for Conformance\Tests\ArraysShapeDiscriminantMatch\coverUrl is not nullable, but 'null|string' contains null [InvalidNullableReturnType]
arrays_shape_discriminant_match.php:32: The declared return type 'string' for Conformance\Tests\ArraysShapeDiscriminantMatch\coverUrl is not nullable, but the function returns 'non-falsy-string|null' [NullableReturnStatement]
arrays_shape_discriminant_match.php:33: Possibly undefined array key $work['image_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Illust'|'Novel'} [PossiblyUndefinedArrayOffset]
arrays_shape_discriminant_match.php:34: Possibly undefined array key $work['cover_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Novel'} [PossiblyUndefinedArrayOffset]
arrays_shape_discriminant_match.php:41: The declared return type 'string' for Conformance\Tests\ArraysShapeDiscriminantMatch\crossedArms is not nullable, but 'null|string' contains null [InvalidNullableReturnType]
arrays_shape_discriminant_match.php:45: The declared return type 'string' for Conformance\Tests\ArraysShapeDiscriminantMatch\crossedArms is not nullable, but the function returns 'non-falsy-string|null' [NullableReturnStatement]
arrays_shape_discriminant_match.php:46: Possibly undefined array key $work['cover_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Illust'|'Novel'} [PossiblyUndefinedArrayOffset]
arrays_shape_discriminant_match.php:47: Possibly undefined array key $work['image_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Novel'} [PossiblyUndefinedArrayOffset]
Expectation diff
Line 26: Unexpected errors ["The declared return type 'string' for Conformance\\Tests\\ArraysShapeDiscriminantMatch\\coverUrl is not nullable, but 'null|string' contains null [InvalidNullableReturnType]"]
Line 32: Unexpected errors ["The declared return type 'string' for Conformance\\Tests\\ArraysShapeDiscriminantMatch\\coverUrl is not nullable, but the function returns 'non-falsy-string|null' [NullableReturnStatement]"]
Line 33: Unexpected errors ["Possibly undefined array key $work['image_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Illust'|'Novel'} [PossiblyUndefinedArrayOffset]"]
Line 34: Unexpected errors ["Possibly undefined array key $work['cover_url'] on array{cover_url?: non-falsy-string, image_url?: non-falsy-string, type: 'Novel'} [PossiblyUndefinedArrayOffset]"]
Line 41: Unexpected errors ["The declared return type 'string' for Conformance\\Tests\\ArraysShapeDiscriminantMatch\\crossedArms is not nullable, but 'null|string' contains null [InvalidNullableReturnType]"]

Source

<?php

declare(strict_types=1);

namespace Conformance\Tests\ArraysShapeDiscriminantMatch;

/**
 * Discriminated union of array shapes, narrowed through `match`
 *
 * A tagged union in PHPDoc: two array shapes sharing a literal-typed `type`
 * key, each carrying a key the other lacks. Matching on the discriminant is
 * supposed to select one member per arm, which decides three things at once —
 * whether the variant-specific read in each arm is a known offset, whether the
 * two literals exhaust the match, and whether reading the *other* variant's
 * key is caught. A tool that keeps the union unnarrowed gets all three wrong:
 * it doubts the valid reads, wants a default arm, and misses the invalid read.
 *
 * References:
 * - PHPStan: match expression narrowing of constant-string unions
 * - Psalm: match on array shape literal keys
 */

/**
 * @param array{type: 'Illust', image_url: non-falsy-string}|array{type: 'Novel', cover_url: non-falsy-string} $work
 */
function coverUrl(array $work): string
{
    // Each arm reads the key only its member has. The two literals cover the
    // whole discriminant type, so no default arm is needed either. A tool that
    // does not narrow reports possibly-undefined offsets or a non-exhaustive
    // match right here, on code the union says is fine.
    return match ($work['type']) {
        'Illust' => $work['image_url'],
        'Novel' => $work['cover_url'],
    };
}

/**
 * @param array{type: 'Illust', image_url: non-falsy-string}|array{type: 'Novel', cover_url: non-falsy-string} $work
 */
function crossedArms(array $work): string
{
    // The same match with the arms swapped: each arm now reads the key its
    // member does not have, which narrowing turns into an undefined offset.
    return match ($work['type']) { // E?: collateral — the failed reads taint the returned type
        'Illust' => $work['cover_url'], // E?: the Illust member has no cover_url key
        'Novel' => $work['image_url'], // E?: the Novel member has no image_url key
    };
}

echo coverUrl(['type' => 'Illust', 'image_url' => 'https://example.com/i.png']);
echo coverUrl(['type' => 'Novel', 'cover_url' => 'https://example.com/c.png']);