Psalm: Template params, string callable functions, and namespaces

Created on 19 Apr 2020  Â·  13Comments  Â·  Source: vimeo/psalm

I'm attempting to use @template with callable like this:

/**
 * @template T
 * @param callable(): (T|Throwable) $get_resource
 * @param callable(T): Result $action
 * @return callable(): Result
 */
function runner($get_resource, $action) {
    return function() use ($get_resource, $action) {
        $resource = $get_resource();
        if ( $resource instanceof Throwable ) {
            throw $resource;
        }
        return $action( $resource );
    };
}

In use, I expect that if the $get_resource's T does not match $actions T then a type error will be raised.

If no namespace is used, then an InvalidArgument is correctly raised:

/**
 * @param OtherThing $thing
 * @return Result
 */
function execute( $thing ) {
    return new class() implements Result {};
}

runner(
    fn() => new Thing(),
    'execute'
);

The error as I expect:

ERROR: InvalidArgument - 30:1 - Type OtherThing should be a subtype of Thing

The Problem

If a namespace is used, the error disappears.

I would expect the same error:

ERROR: InvalidArgument - 32:1 - Type Hello\OtherThing should be a subtype of Hello\Thing

But there is no error.

bug

All 13 comments

I found these snippets:


https://psalm.dev/r/640d53013d

<?php

interface Result {}
class Thing {}
class OtherThing {}

/**
 * @template T
 * @param callable(): (T|Throwable) $get_resource
 * @param callable(T): Result $action
 * @return callable(): Result
 */
function runner($get_resource, $action) {
    return function() use ($get_resource, $action) {
        $resource = $get_resource();
        if ( $resource instanceof Throwable ) {
            throw $resource;
        }
        return $action( $resource );
    };
}

/**
 * @param OtherThing $thing
 */
function execute( $thing ) {
    return new class() implements Result {};
}

runner(
    fn() => new Thing(),
    'execute'
);
Psalm output (using commit 41e6ef6):

INFO: UnusedParam - 26:19 - Param $thing is never referenced in this method

INFO: MissingReturnType - 26:10 - Method execute does not have a return type, expecting _var_www_vhosts_psalm_dev_httpdocs_src____src_somefile_php_27_550

ERROR: InvalidArgument - 30:1 - Type OtherThing should be a subtype of Thing


https://psalm.dev/r/bcbd96e22f

<?php

namespace Hello;

interface Result {}
class Thing {}
class OtherThing {}

/**
 * @template T
 * @param callable(): (T|\Throwable) $get_resource
 * @param callable(T): Result $action
 * @return callable(): Result
 */
function runner($get_resource, $action) {
    return function() use ($get_resource, $action) {
        $resource = $get_resource();
        if ( $resource instanceof \Throwable ) {
            throw $resource;
        }
        return $action( $resource );
    };
}

/**
 * @param OtherThing $thing
 * @return Result
 */
function execute( $thing ) {
    return new class() implements Result {};
}

runner(
    fn() => new Thing(),
    'Hello\execute'
);
Psalm output (using commit 41e6ef6):

INFO: UnusedParam - 29:19 - Param $thing is never referenced in this method

In your second example, you refer to the function as Hello\execute from within Hello namespace - so you're actually calling Hello\Hello\execute which does not exist: https://3v4l.org/9WQE8

When this is fixed, Psalm correctly reports the issue: https://psalm.dev/r/fd7e115eaa

I found these snippets:


https://psalm.dev/r/fd7e115eaa

<?php

namespace Hello;

interface Result {}
class Thing {}
class OtherThing {}

/**
 * @template T
 * @param callable(): (T|\Throwable) $get_resource
 * @param callable(T): Result $action
 * @return callable(): Result
 */
function runner($get_resource, $action) {
    return function() use ($get_resource, $action) {
        $resource = $get_resource();
        if ( $resource instanceof \Throwable ) {
            throw $resource;
        }
        return $action( $resource );
    };
}

/**
 * @param OtherThing $thing
 * @return Result
 */
function execute( $thing ) {
    return new class() implements Result {};
}

runner(
    fn() => new Thing(),
    'execute'
);
Psalm output (using commit 41e6ef6):

INFO: UnusedParam - 29:19 - Param $thing is never referenced in this method

ERROR: InvalidArgument - 33:1 - Type Hello\OtherThing should be a subtype of Hello\Thing

Actually string callables need to be fully qualified (I was wrong above): works, fails.

So the bug on Psalm's part is that it qualifies string callables, but it should not.

Also the failure is limited to functions from the calling namespace, functions from other namespaces are resolved properly: https://psalm.dev/r/26c638224c

I found these snippets:


https://psalm.dev/r/26c638224c

<?php

namespace NS;

function runner(callable $_action): void {}

function execute(): void {}

runner('OtherNS\execute');
Psalm output (using commit 41e6ef6):

ERROR: UndefinedFunction - 9:8 - Function OtherNS\execute does not exist

So to recap:

1) If Hello\execute is a function that doesn’t exist, I would expect Psalm to fail it it’s used as a callable.
2) Using \Hello\execute should fail with InvalidArgument but doesn’t, it behaves the same way: https://psalm.dev/r/de3c0df2f1

I found these snippets:


https://psalm.dev/r/de3c0df2f1

<?php

namespace Hello;

interface Result {}
class Thing {}
class OtherThing {}

/**
 * @template T
 * @param callable(): (T|\Throwable) $get_resource
 * @param callable(T): Result $action
 * @return callable(): Result
 */
function runner($get_resource, $action) {
    return function() use ($get_resource, $action) {
        $resource = $get_resource();
        if ( $resource instanceof \Throwable ) {
            throw $resource;
        }
        return $action( $resource );
    };
}

/**
 * @param OtherThing $thing
 * @return Result
 */
function execute( $thing ) {
    return new class() implements Result {};
}

runner(
    fn() => new Thing(),
    '\Hello\execute'
);
Psalm output (using commit 41e6ef6):

INFO: UnusedParam - 29:19 - Param $thing is never referenced in this method

Here's a sample that shows the same problem even when the function is in a different namespace as the string callable:

https://psalm.dev/r/44f133751c

I found these snippets:


https://psalm.dev/r/44f133751c

<?php

namespace Hello;

interface Result {}
class Thing {}
class OtherThing {}

/**
 * @template T
 * @param callable(): (T|\Throwable) $get_resource
 * @param callable(T): Result $action
 * @return callable(): Result
 */
function runner($get_resource, $action) {
    return function() use ($get_resource, $action) {
        $resource = $get_resource();
        if ( $resource instanceof \Throwable ) {
            throw $resource;
        }
        return $action( $resource );
    };
}

namespace Other;

/**
 * @param \Hello\OtherThing $thing
 * @return \Hello\Result
 */
function execute( $thing ) {
    return new class() implements \Hello\Result {};
}

namespace Third;

\Hello\runner(
    fn() => new \Hello\Thing(),
    '\Other\execute'
);
Psalm output (using commit 41e6ef6):

INFO: UnusedParam - 31:19 - Param $thing is never referenced in this method

Is there a way to test the changes?

I attempted to install psalm/phar=dev-master but it fails to find the failing scenario still. From what I can tell it _does_ include the fix from a3ae2a7.

Step 0) composer require --dev psalm/phar=dev-master
Step 1) create non-namespace code:

<?php

/**
 * @template T
 * @param callable():T $get_resource
 * @param callable(T):void $action
 * @return callable():void
 */
function execute( $get_resource, $action ) {
    return function() use ( $get_resource, $action ) {
        $resource = $get_resource();
        $action( $resource );
    };
}

class ThingA {}
class ThingB {}

execute(
    fn() => new ThingA(),
    'execute_thingb'
);

/**
 * @param ThingB $thing
 * @return void
 */
function execute_thingb( $thing) {

}

Note that it successfully detects that the return value for T does not match the @param type of execute_thingb because ThingA and ThingB are not compatible types for T.

 ./vendor/bin/psalm.phar src
Scanning files...
Analyzing files...

â–‘Eâ–‘

ERROR: InvalidArgument - src/other.php:19:1 - Type ThingB should be a subtype of ThingA (see https://psalm.dev/004)
execute(
    fn() => new ThingA(),
    'execute_thingb'
);


------------------------------
1 errors found
------------------------------

Step 2) change the file to use a namespace:

diff --git a/src/other.php b/src/other.php
index 76b4a3b..468dae7 100644
--- a/src/other.php
+++ b/src/other.php
@@ -1,4 +1,5 @@
 <?php
+namespace Foo;

 /**
  * @template T
@@ -18,7 +19,7 @@ class ThingB {}

 execute(
    fn() => new ThingA(),
-   'execute_thingb'
+   '\Foo\execute_thingb'
 );

 /**

Step 3) Run psalm again:

./vendor/bin/psalm.phar src
Scanning files...
Analyzing files...

â–‘â–‘â–‘

------------------------------
No errors found!
------------------------------

The same error is present, but Psalm failed to find it.

To make sure I'm using the correct psalm binary, if I keep the namespace but remove the fully qualified string callable name, Psalm correctly reports the UndefinedFunction error as introduced by a3ae2a7.

./vendor/bin/psalm.phar src
Scanning files...
Analyzing files...

â–‘Eâ–‘

ERROR: UndefinedFunction - src/other.php:22:2 - Function execute_thingb does not exist (see https://psalm.dev/021)
    'execute_thingb'


------------------------------
1 errors found
------------------------------

Same problem when there are two different namespaces:

diff --git a/src/other.php b/src/other.php
index 76b4a3b..1f8e8a3 100644
--- a/src/other.php
+++ b/src/other.php
@@ -1,4 +1,5 @@
 <?php
+namespace Foo;

 /**
  * @template T
@@ -13,12 +14,14 @@ function execute( $get_resource, $action ) {
    };
 }

+namespace Bar;
+
 class ThingA {}
 class ThingB {}

-execute(
+\Foo\execute(
    fn() => new ThingA(),
-   'execute_thingb'
+   '\Bar\execute_thingb'
 );

 /**
./vendor/bin/psalm.phar src
Scanning files...
Analyzing files...

â–‘â–‘â–‘

------------------------------
No errors found!
------------------------------

Checks took 5.87 seconds and used 417.671MB of memory
Psalm was able to infer types for 99.3421% of the codebase

composer require --dev psalm/phar=dev-master

composer require --dev vimeo/psalm:dev-master should be the actual latest, without any potential phar issues.

Was this page helpful?
0 / 5 - 0 ratings