Summary
When a function's return type is inferred (not declared) and the returned expression merges an object with null — via ternary or match — the checker erases the nullability: the null path materializes as an object-typed value and gettype() reports object where PHP reports NULL.
<?php
function pick(int $n) {
return $n === 0 ? new stdClass() : null; // same with match($n) { 0 => new stdClass(), default => null }
}
echo gettype(pick(0)), "|", gettype(pick(1));
Declaring the return type (: mixed or : ?stdClass) makes both constructs behave correctly, so this is specifically the inferred-return boundary.
Mechanism
The checker types the null literal as Void, and both merge rules let the non-Void side win (the ternary inference keeps then_ty/the other branch; the match inference — after #492 — deliberately treats Void as a value-less pass-through so throw/null arms don't poison the merge). The inferred return type therefore comes out as plain Object("stdClass"), while the EIR lowering's merge temp is nullable (?Object), and the return coercion to the checker's Object erases the null.
Worse than the wrong gettype, the null path then flows on as a "valid" object: pick(1)->prop reads garbage (with an undefined-property warning) instead of PHP's "Attempt to read property on null" warning + NULL.
Expected
The inferred return type for these merges should preserve nullability (e.g. ?T / a Void|T union, or at minimum Mixed), so the null path survives the return boundary like it already does with a declared mixed return type.
Affects ternary and match identically; pre-existing (reproduced on main before the match work). Verified on macOS ARM64.
Summary
When a function's return type is inferred (not declared) and the returned expression merges an object with
null— via ternary or match — the checker erases the nullability: thenullpath materializes as an object-typed value andgettype()reportsobjectwhere PHP reportsNULL.mainand after fix: match with heterogeneous arm types no longer coerces every arm to one unified type #492):object|objectobject|NULLDeclaring the return type (
: mixedor: ?stdClass) makes both constructs behave correctly, so this is specifically the inferred-return boundary.Mechanism
The checker types the
nullliteral asVoid, and both merge rules let the non-Voidside win (the ternary inference keepsthen_ty/the other branch; the match inference — after #492 — deliberately treatsVoidas a value-less pass-through sothrow/nullarms don't poison the merge). The inferred return type therefore comes out as plainObject("stdClass"), while the EIR lowering's merge temp is nullable (?Object), and the return coercion to the checker'sObjecterases the null.Worse than the wrong
gettype, the null path then flows on as a "valid" object:pick(1)->propreads garbage (with an undefined-property warning) instead of PHP's "Attempt to read property on null" warning +NULL.Expected
The inferred return type for these merges should preserve nullability (e.g.
?T/ aVoid|Tunion, or at minimumMixed), so the null path survives the return boundary like it already does with a declaredmixedreturn type.Affects ternary and match identically; pre-existing (reproduced on
mainbefore the match work). Verified on macOS ARM64.