Summary
When @psalm-assert-if-true !='' $value is applied to an input containing a class-string<X> atomic, Psalm adds a spurious non-empty-string atomic to the narrowed union instead of preserving the original class-string<X>. Since class-string<X> is already a subtype of non-empty-string, the assertion gives no new information, but the resulting union breaks downstream call sites like new $x() and $x::staticMethod() with InvalidStringClass.
Reproducer
<?php
/**
* @param mixed $value
* @psalm-assert-if-true !null $value
* @psalm-assert-if-true !='' $value
* @psalm-pure
*/
function isFilled($value): bool
{
return $value !== null && $value !== '';
}
/**
* Control: only !null, without !=''.
*
* @param mixed $value
* @psalm-assert-if-true !null $value
* @psalm-pure
*/
function isFilledControl($value): bool
{
return $value !== null;
}
/**
* @return ?class-string<Throwable>
* @psalm-pure
*/
function getClass(): ?string { return null; }
function with_bad_assertion(): void
{
$fqcn = getClass();
if (isFilled($fqcn)) {
/** @psalm-check-type-exact $fqcn = class-string<Throwable> */;
}
}
function with_control_assertion(): void
{
$fqcn = getClass();
if (isFilledControl($fqcn)) {
/** @psalm-check-type-exact $fqcn = class-string<Throwable> */;
}
}
function downstream_invalid_string_class(): void
{
/** @var ?class-string<DateTime> $cls */
$cls = null;
if (isFilled($cls)) {
$cls::createFromFormat('Y-m-d', '2024-01-01');
}
}
Expected
In both with_bad_assertion() and with_control_assertion(), $fqcn is class-string<Throwable> inside the true branch, and downstream_invalid_string_class() raises no issue (the static call is valid on a class-string<DateTime>).
Actual
ERROR: CheckType (at with_bad_assertion)
Checked variable $fqcn = class-string<Throwable> does not match $fqcn = class-string<Throwable>|non-empty-string
ERROR: InvalidStringClass (at downstream_invalid_string_class)
String cannot be used as a class
with_control_assertion() emits no CheckType, confirming that @psalm-assert-if-true !='' is the source. The !='' assertion is being applied to every atomic of the input union, and for class-string<X> it adds a non-empty-string atomic (even though class-string<X> is already a subtype of non-empty-string).
Impact
Real-world fallout in psalm/psalm-plugin-laravel#771: Laravel's filled() helper stub used this assertion pair to tighten ?string narrowing to non-empty-string. Benchmarking the plugin on Filament v4.8.3 vs v4.8.4 showed +35 new issues stemming from the assertion, including 14 InvalidStringClass on filled($classString = ...) && $classString::method() call sites. The assertion has since been reverted in the plugin, but the Psalm behavior should be fixed so !='' can be combined with class-string<X> inputs without widening.
Version
Psalm 7.0.0-beta19@7e751c06a756fa64dc4c759c09fe4a173afcb433
Summary
When
@psalm-assert-if-true !='' $valueis applied to an input containing aclass-string<X>atomic, Psalm adds a spuriousnon-empty-stringatomic to the narrowed union instead of preserving the originalclass-string<X>. Sinceclass-string<X>is already a subtype ofnon-empty-string, the assertion gives no new information, but the resulting union breaks downstream call sites likenew $x()and$x::staticMethod()withInvalidStringClass.Reproducer
Expected
In both
with_bad_assertion()andwith_control_assertion(),$fqcnisclass-string<Throwable>inside the true branch, anddownstream_invalid_string_class()raises no issue (the static call is valid on aclass-string<DateTime>).Actual
with_control_assertion()emits noCheckType, confirming that@psalm-assert-if-true !=''is the source. The!=''assertion is being applied to every atomic of the input union, and forclass-string<X>it adds anon-empty-stringatomic (even thoughclass-string<X>is already a subtype ofnon-empty-string).Impact
Real-world fallout in psalm/psalm-plugin-laravel#771: Laravel's
filled()helper stub used this assertion pair to tighten?stringnarrowing tonon-empty-string. Benchmarking the plugin on Filament v4.8.3 vs v4.8.4 showed +35 new issues stemming from the assertion, including 14InvalidStringClassonfilled($classString = ...) && $classString::method()call sites. The assertion has since been reverted in the plugin, but the Psalm behavior should be fixed so!=''can be combined withclass-string<X>inputs without widening.Version
Psalm 7.0.0-beta19@7e751c06a756fa64dc4c759c09fe4a173afcb433