matchtype
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 | Version | Result | Diagnostics |
|---|---|---|---|
| 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-rulesarrays_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 diffLine 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 diffLine 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 diffLine 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 diffLine 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 diffLine 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 diffLine 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 diffLine 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]"] |
<?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']);