Skip to content

StubsGenerator crashes with UnexpectedValueException when signature return type contains string|int|float|bool (collapsed to TScalar) #11805

@alies-dev

Description

@alies-dev

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:

  1. 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.

  2. 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;
}
  1. The stub generator stores this collapsed TScalar|null as the method's signature_return_type.

  2. StubsGenerator::getParserTypeFromPsalmType() hits the instanceof Scalar branch and calls $atomic_type->toPhpString() — which returns null for TScalar because scalar is not valid PHP syntax.

  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions