vimeo / psalm

A static analysis tool for finding errors in PHP applications
https://psalm.dev
MIT License
5.57k stars 660 forks source link

Pseudo-types for referencing class properties and methods? #3141

Open Ocramius opened 4 years ago

Ocramius commented 4 years ago

Just throwing out vague idea: there is no implementation nor clear concept, so please feel free to close, but I hope it will be useful to brainstorm this amongst type-junkies.

I was considering adding types for ReflectionMethod and ReflectionProperty, but it seems to be tricky to do that, due to the lack of a system to reference properties and classes at type-level.

Specifically, parameter $name of ReflectionClass<class-string>#getProperty($name) could be a class-property<class-string, non-empty-string, PropertyType of mixe>, which could be used for type resolution.

Similarly, parameter $name of ReflectionClass<class-string>#getMethod($name) could be a ReflectionMethod<class-method<class-string, non-empty-string, list<TParameter of mixed>, MethodType of mixed>>, which could be used for type resolution, and class-method could be a callable when combined with a class-string<T> or an object of T that is compatible with that class-method<T, ...>.

This sort of API is interesting for things like hydrators:

/**
 * @psalm-template TObject of object
 * 
 * NOTE: how do we type `mixed` below here?
 *
 * @psalm-param non-empty-array<
 *   class-property<class-string<TObject>, non-empty-string>,
 *   mixed,
 *   mixed
 * >
 * @psalm-return TObject
 */
function hydrate(array $input, object $object) { /** ... */ }
$object = hydrate(
    [
        'foo' => $bar,
        'baz' => $tab,
    ],
    $object
);

Important in the above is that I still don't have an idea of how we could type the mixed

Also for assertions:

/**
 * @psalm-assert TObject of object
 * @psalm-assert TParameter of mixed
 * @psalm-param non-empty-array<
 *   class-property<class-string<TObject>, non-empty-string, list<TParameter> mixed>,
 *   mixed
 * >
 * @psalm-assert class-method<class-string<TObject>, non-empty-string> $method
 */
function assertMethodExists(string $method, object $object) { /** ... */ }

function findObject() : object {}
function findMethod() : string {}

$object = findObject();
$method = findMethod();

assertMethodExists($object, $method);

$object->$method(); // not sure about parameter count and parameter types
weirdan commented 4 years ago

This looks like something like Typescript's structural typing, so perhaps something could be borrowed from their approach.

Ocramius commented 4 years ago

I've added some bells & whistles above: I couldn't find anything about Typescript's reflection support though. Structural typing seems very similar to GO's interfaces?

weirdan commented 4 years ago

Apologies, I used wrong terminology. What I meant was index types.

Usage example:

// ------------------- [Definitions] ---------------
class ReflectionProperty<T> {
    prop: T;
    constructor(prop: T) {
        this.prop = prop
    }
    getValue(): T {
        return this.prop
    }
}

class ReflectionObject<T extends object> {
    obj: T;
    constructor(obj: T) {
        this.obj = obj
    }
    getProperty<N extends keyof T>(prop: N): ReflectionProperty<T[N]> {
        return new ReflectionProperty(this.obj[prop]);
    }
}

function takesNumber(val: number): void { }

// -------------------- [Usage] -------------

class C {
    prop: string = 'zxc';
    func(): number {
        return 123;
    }
}
class D {
    e: number = 123;
    func(): string {
        return "zxc";
    }
}

// here typescript figures out getValue returns string, not int
takesNumber(new ReflectionObject(new C).getProperty('prop').getValue()) 

// this is fine
takesNumber(new ReflectionObject(new D).getProperty('e').getValue())

// works with functions too
const cFunc = new ReflectionObject(new C).getProperty('func').getValue()
takesNumber(cFunc())

// knows that dFunc returns string
const dFunc = new ReflectionObject(new D).getProperty('func').getValue()
takesNumber(dFunc())
glennpratt commented 3 years ago

I'm also interested in this. Another example is Symfony's EventSubscriberInterface which maps events to instance methods on the same class:

https://github.com/symfony/event-dispatcher/blob/5.x/EventSubscriberInterface.php#L27-L48

veewee commented 3 years ago

Structural typing would also be a nice addition for DTO classes with public readonly properties:

class Human {
    public function __construct(
        public readonly string $name 
    ){}
}

class Animal {
    public function __construct(
        public readonly string $name 
    ){}
}

/** @psalm-assert shape<shapeof Human> shape<shapeof  Animal> */

This syntax might look like a mix between hack's shape keyword and typescript's structural typing + keywords.

Since there is already an array shape, the shape won't be an array as it is in hack. The difference between a shape and an existing array shape would be the way how it is compared.

For structural shapes, a structural comparison could be done. For arrays the comparison would be stricter.

I've translated the examples above to what it could look like:

Given:


class WindowsServer {
    public string $name = 'X';
    public int $age = 10;
}

class LinuxServer {
    public string $name = 'X';
    public int $age = 10;
    public string $kernel = '5.14';
    public function execute(Foo $foo): Bar{}
}

interface Executor {
    public function execute(Foo $foo): Bar{};
}

These additional structures could be allowed:

Direct usage with props:

/**
 * @psalm-type Server = shape{name: string, age: int}
 * @param Server $windowsServer
 * @param Server $linuxServer
 */

Support for callables / methods:

/**
 * @psalm-type Server = shape{execute: (callable(Foo): Bar)}
 * @param Server $linuxServer
 * @param Server $someOtherExecutorThatImplementsTheExecutorInterface
 */

Type operations (like typescript's Typeof or Keyof) that result in enumerations of object information:

/**
 * @psalm-type Methods = methodsof LinuxServer           - enum of all public method names
 * @psalm-type Types = propsof LinuxServer               - enum of all (public?) properties
 * @psalm-type Shapeof = shapeof LinuxServer             - enum of both methods and props
 */

Casting of objects to structural shapes or array shapes:

/**
 * @psalm-type Server = shape<propsof WindowsServer>     - (Public?) properties only
 * @psalm-type Executor = shape<methodsof LinuxServer>   - Public methods only
 * @psalm-type arrayInfo = array<propsof LinuxServer>    - Special functions also useable for arrays
 * @psalm-type LinuxShape = shape<shapeof LinuxServer>   - Full shape : props + methods
 */

The hydration example could look like this:

/**
 * @template T of object
 * @param array<propsof T> $input
 * @param T $object
 * @return T
 */
function hydrate(array $input, object $object): object {}

Note : this will require the array to contain ALL the properties of the object. If you want to limit the options, there will be a need for typescript-like utility types like

The assertion example could look like this:

/**
 * @template TObject of object
 * @template TParameter of string
 *
 * @psalm-assert shape{TParameter: callable} TObject
 *
 * @param TParameter $method
 * @param TObject $object
 */
function assertMethodExists(string $method, object $object) { /** ... */ }

The event subscriber could look like this:

/**
 * @psalm-type MethodNames = methodsof static
 *
 * @psalm-type Simple = array<string, MethodNames>
 * @psalm-type Prioritized = array<string, array{ MethodNames, int }>
 * @psalm-type Many = array<array-key, Simple|Prioritized>
 *
 * @return array<string, Simple|Prioritized|Many>
*/
public static function getSubscribedEvents();

Not sure how doable the above list is. But it is an interesting analysis nevertheless!

aurimasniekis commented 2 years ago

I was able to make class-property type using properties available from class like storage, dunno if it's a good way, but I do not see why it should not be possible to implement. I just don't know if it's possible to make like @veewee proposed props of T or methodsof T syntax

image

Patrick-Remy commented 2 years ago

Do you plan to raise a PR? I would love to test and give you feedback about the new type-annotation class-property.

aurimasniekis commented 2 years ago

Yeah, I will try to make a PR when I finish a few other things with it. I am yet to figure out the internals of the psalm, I want to add some filters like visibility, type :)

edit: That autocomplete was just me trying out adding autocomplete to language server 😅