Bug Report
Description
Running Psalm with --generate-stubs crashes with an uncaught UnexpectedValueException when a method's signature return type is string|int|float|bool|null. Psalm's TypeCombiner collapses string|int|float|bool to the internal TScalar pseudo-type, but the stub generator cannot convert TScalar back to a valid PHP type node because scalar is not a valid PHP type keyword.
Error
Uncaught UnexpectedValueException: scalar could not be converted to an identifier
in .../vendor/vimeo/psalm/src/Psalm/Internal/Stubs/Generator/StubsGenerator.php:329
Stack trace:
#0 .../ClassLikeStubGenerator.php(283): StubsGenerator::getParserTypeFromPsalmType(Object(Psalm\Type\Union))
#1 .../ClassLikeStubGenerator.php(42): ClassLikeStubGenerator::getMethodNodes(Object(Psalm\Storage\ClassLikeStorage))
#2 .../StubsGenerator.php(89): ClassLikeStubGenerator::getClassLikeNode(..., 'InputBag')
#3 .../Psalm.php(1373): StubsGenerator::getAll(...)
#4 .../Psalm.php(421): Psalm::generateStubs(...)
Root Cause
The bug is a round-trip failure in the stub generator:
-
InputBag::get() (Symfony symfony/http-foundation) has a PHP return type of string|int|float|bool|null and a docblock @return TDefault|TInput where both templates are bounded by string|int|float|bool|null.
-
When Psalm resolves the type union, TypeCombiner collapses string|int|float|bool into the internal TScalar pseudo-type (TypeCombiner.php:327–338):
if (isset($combination->value_types['string'])
&& isset($combination->value_types['int'])
&& isset($combination->value_types['bool'])
&& isset($combination->value_types['float'])
) {
unset(...);
$combination->value_types['scalar'] = new TScalar;
}
-
The stub generator stores this collapsed TScalar|null as the method's signature_return_type.
-
StubsGenerator::getParserTypeFromPsalmType() hits the instanceof Scalar branch and calls $atomic_type->toPhpString() — which returns null for TScalar because scalar is not valid PHP syntax.
-
Line 329 throws the exception:
if ($identifier_string === null) {
throw new UnexpectedValueException(
$atomic_type->getId() . ' could not be converted to an identifier'
);
}
Expected Behavior
The stub generator should handle TScalar gracefully — either by expanding it back to string|int|float|bool or by skipping the return type declaration (falling back to no return type in the stub).
Suggested Fix
In StubsGenerator::getParserTypeFromPsalmType(), add a special case before the instanceof Scalar branch to expand TScalar into its constituent types:
if ($atomic_type instanceof TScalar && !$atomic_type instanceof TString && ...) {
// TScalar has no PHP equivalent; expand to string|int|float|bool
// or return null/mixed
}
Alternatively, TypeCombiner could avoid collapsing string|int|float|bool to TScalar when the types originate from a PHP signature (as opposed to a docblock-inferred type).
Psalm Version
Psalm dev-master@d9c74776b9c98d459dd7f98b12e0ab0daf4af721
Reproducer
Any class that overrides a method and has a PHP return type of string|int|float|bool|null, combined with a docblock template return type whose bounds resolve to the same union. Symfony's InputBag::get() triggers this reliably:
// symfony/http-foundation InputBag::get()
/** @template TDefault of string|int|float|bool|null */
/** @return TDefault|TInput */
public function get(string $key, mixed $default = null): string|int|float|bool|null
Run: vendor/bin/psalm --generate-stubs=stubs.php
Bug Report
Description
Running Psalm with
--generate-stubscrashes with an uncaughtUnexpectedValueExceptionwhen a method's signature return type isstring|int|float|bool|null. Psalm'sTypeCombinercollapsesstring|int|float|boolto the internalTScalarpseudo-type, but the stub generator cannot convertTScalarback to a valid PHP type node becausescalaris not a valid PHP type keyword.Error
Root Cause
The bug is a round-trip failure in the stub generator:
InputBag::get()(Symfonysymfony/http-foundation) has a PHP return type ofstring|int|float|bool|nulland a docblock@return TDefault|TInputwhere both templates are bounded bystring|int|float|bool|null.When Psalm resolves the type union,
TypeCombinercollapsesstring|int|float|boolinto the internalTScalarpseudo-type (TypeCombiner.php:327–338):The stub generator stores this collapsed
TScalar|nullas the method'ssignature_return_type.StubsGenerator::getParserTypeFromPsalmType()hits theinstanceof Scalarbranch and calls$atomic_type->toPhpString()— which returnsnullforTScalarbecausescalaris not valid PHP syntax.Line 329 throws the exception:
Expected Behavior
The stub generator should handle
TScalargracefully — either by expanding it back tostring|int|float|boolor by skipping the return type declaration (falling back to no return type in the stub).Suggested Fix
In
StubsGenerator::getParserTypeFromPsalmType(), add a special case before theinstanceof Scalarbranch to expandTScalarinto its constituent types:Alternatively,
TypeCombinercould avoid collapsingstring|int|float|booltoTScalarwhen the types originate from a PHP signature (as opposed to a docblock-inferred type).Psalm Version
Reproducer
Any class that overrides a method and has a PHP return type of
string|int|float|bool|null, combined with a docblock template return type whose bounds resolve to the same union. Symfony'sInputBag::get()triggers this reliably:Run:
vendor/bin/psalm --generate-stubs=stubs.php