Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lacking support for key-of<properties-of<T>> #9367

Open
boesing opened this issue Feb 21, 2023 · 4 comments
Open

Lacking support for key-of<properties-of<T>> #9367

boesing opened this issue Feb 21, 2023 · 4 comments

Comments

@boesing
Copy link
Contributor

boesing commented Feb 21, 2023

In #7359, properties-of feature was introduced.

I'd love to use this and almost directly stumbled upon the use-case mentioned in the title.

After reading latest comments in #7359, the lack of supporting generics in combination with properties-of and key-of was already detected.

I'd like to add this as a feature request for now, maybe either @Patrick-Remy, @aurimaskiekis or even myself might implement this at some point in time.


@Patrick-Remy thanks for implementing this feature, but I am having some trouble trying to use it. I have tried many different combinations but I really can't seem to find a way to make it work.

P.S. I am trying on dev-master branch

Few examples:

<?php

class Test
{
    /**
     * @template T of object
     *
     * @param T                        $object
     * @param key-of<properties-of<T>> $name
     *
     * @return void
     */
    public static function check(object $object, string $name): void
    {
    }
}

class Data
{
    public string $foo = 'foo!';
    public int    $bar = 42;
}

$data = new Data();

Test::check($data, 'aa');
ERROR: InvalidDocblock - a.php:74:15 - Untemplated key-of param properties-of<T> should be an array in docblock for Test::check (see https://psalm.dev/008)
     * @param key-of<properties-of<T>> $name
<?php

class Test
{
    /**
     * @template T of object
     * @template P of properties-of<T>
     *
     * @param T         $object
     * @param key-of<P> $name
     *
     * @return void
     */
    public static function check(object $object, string $name): void
    {
    }
}

class Data
{
    public string $foo = 'foo!';
    public int    $bar = 42;
}

$data = new Data();

Test::check($data, 'foo');
Test::check($data, 'aa');
ERROR: MismatchingDocblockParamType - a.php:75:15 - Parameter $name has wrong type 'key-of<P>', should be 'string' (see https://psalm.dev/141)
     * @param key-of<P> $name


ERROR: InvalidArgument - a.php:92:20 - Argument 2 of Test::check expects key-of<P:fn-test::check as properties-of<T:fn-test::check as object>>, 'foo' provided (see https://psalm.dev/004)
Test::check($data, 'foo');


ERROR: InvalidArgument - a.php:93:20 - Argument 2 of Test::check expects key-of<P:fn-test::check as properties-of<T:fn-test::check as object>>, 'aa' provided (see https://psalm.dev/004)
Test::check($data, 'aa');

Also key-of<T> doesn't look like works and just says no issues:

<?php

class Test
{
    /**
     * @template T of array
     *
     * @param T         $input
     * @param key-of<T> $name
     *
     * @return void
     */
    public static function check(array $input, string $name): void
    {
    }
}

$data = ['foo' => 'ddd', 'bar' => 42];

Test::check($data, 'foo');
Test::check($data, 'aa');

Tried even providing array shape but no luck.

/** @var array{foo: string, bar: int} $data */

Originally posted by @aurimasniekis in #7359 (comment)

@psalm-github-bot
Copy link

Hey @boesing, can you reproduce the issue on https://psalm.dev ?

@veewee
Copy link
Contributor

veewee commented Nov 8, 2023

Cool feature! On top of what is mentioned above, I think something like this is also missing for collecting the value type.

class Test
{
    /**
     * @template T of object
     * @template P of properties-of<T>
     *
     * @param T         $object
     * @param key-of<P> $name
     *
     * @return T[P] 
     */
    public static function get(object $object, string $name): mixed
    {
    }
}

class Data
{
    public string $foo = 'foo!';
    public int    $bar = 42;
}

$data = new Data();

Test::get($data, 'foo'); // string
Test::get($data, 'aa'); // int

In the exmple you can see the return type of:

@return T[P] 

This is in line with what is acceptable on arrays already:
https://psalm.dev/r/e8e7cbbdd0

Not sure if that syntax makes a lot of sense for objects, but it's also how it works in typescript so it might make sense:

const x = {firstName: 'a', lastName: 1};

function get<X extends object, K extends keyof X>(object: X, key : K): X[K] {
    return object[key];    
}

const a : string = get(x, 'firstName');
const b : number = get(x, 'lastName');

Maybe it's not very relevant, but I tried creating a psalm plugin for the example get() function which tries to infer to value type given a class and a parameter. Currently it only works on TNamedObject in combination with TLiteralString - but I can assume it could work with key-of<property-of<T>> as well.

it looks like this:

function property_get(object $object, string $name): mixed {
    $classInfo = new \ReflectionClass($object);
    $propInfo = $classInfo->getProperty($name);

    return $propInfo->getValue($object);
}
use PhpParser\Node\Arg;
use Psalm\Internal\Codebase\Reflection;
use Psalm\Plugin\ArgTypeInferer;
use Psalm\Plugin\DynamicFunctionStorage;
use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface;
use Psalm\Plugin\EventHandler\Event\DynamicFunctionStorageProviderEvent;
use Psalm\Storage\FunctionLikeParameter;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Union;

class PropertyGetProvider implements DynamicFunctionStorageProviderInterface
{
    /**
     * @return array<lowercase-string>
     */
    public static function getFunctionIds(): array
    {
        return ['property_get'];
    }

    public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage
    {
        $args = $event->getArgs();
        $inferrer = $event->getArgTypeInferer();

        $objectType = self::inferObjectType($inferrer, $args[0]);
        if (!$objectType) {
            return null;
        }

        $propertyNameType = self::inferPropertyNameType($inferrer, $args[1]);
        if (!$propertyNameType) {
            return null;
        }

        try {
            $rc = new \ReflectionClass($objectType->value);
            $prop = $rc->getProperty($propertyNameType->value);
        } catch (\ReflectionException) {
            return null;
        }

        $inferredReturnType = Reflection::getPsalmTypeFromReflectionType($prop->getType());

        $storage = new DynamicFunctionStorage();
        $storage->params = [
            new FunctionLikeParameter('object', false, new Union([$objectType])),
            new FunctionLikeParameter('name', false, new Union([$propertyNameType])),
        ];
        $storage->return_type = $inferredReturnType;

        return $storage;
    }

    private static function inferObjectType(ArgTypeInferer $inferer, Arg $arg): TNamedObject | TTemplateParam | null
    {
        $objectTypeUnion = $inferer->infer($arg);
        if (!$objectTypeUnion->isSingle()) {
            return null;
        }

        /** @var TNamedObject | TTemplateParam | null $objectType */
        $objectType = $objectTypeUnion->getSingleAtomic();
        if (!$objectType->isNamedObjectType()) {
            return null;
        }

        return $objectType;
    }

    private static function inferPropertyNameType(ArgTypeInferer $inferer, Arg $arg): TLiteralString | null
    {
        $propertyNameTypeUnion = $inferer->infer($arg);
        if (!$propertyNameTypeUnion->isSingleStringLiteral()) {
            return null;
        }

        return $propertyNameTypeUnion->getSingleStringLiteral();
    }
}

Looking forward to getting more out of this feature (and maybe even getting it to work on objects with dynamic props :o )! :)

Copy link

I found these snippets:

https://psalm.dev/r/e8e7cbbdd0
<?php
$x = ['a' => 'a', 'b' => 123];

/**
 * @template A of array
 * @template K of key-of<A>
 *
 * @param A $array
 * @param K $key
 * @return A[K]
 */
function x(array $array, string $key): mixed
{
    return $array[$key];
}

$a = x($x, 'a');
$b = x($x, 'b');

/** @psalm-trace $a, $b */
Psalm output (using commit b775d29):

INFO: Trace - 20:27 - $a: 'a'

INFO: Trace - 20:27 - $b: 123

INFO: UnusedVariable - 17:1 - $a is never referenced or the value is not used

INFO: UnusedVariable - 18:1 - $b is never referenced or the value is not used

@rzvc
Copy link

rzvc commented Jan 21, 2024

There is also this slight, but important variation, where K can be the union of multiple keys, so T[K] will have to be contravariantly or covariantly composed based on where it's being used:

/**
 * @template T extends object
 * @template K extends key-of<properties-of<T>>
 * 
 * @param T $object
 * @param list<K> $key
 * @param key-of<properties-of<T[K]>> $sub_key
 * @return T[K]
*/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants