Skip to content

[Bug]: Inherited property default is checked against the subclass's template binding, so constructing the subclass throws (PHPStan accepts it) #110

Closed

Description

@fdaviddpt

Summary

A base class declares a property typed with its own class template and gives it an empty default:

/** @var TOptions */
protected array $options = [];

A subclass binds the template to a shape with a required key (@extends Command<array{name: string}>). TypePHP then rejects constructing the subclass, before any code has assigned the property:

Property App\GetAgency::$options is missing required key 'name'

PHPStan (level 9) accepts the same code. This is a divergence from PHPStan, in the spirit of #67 and #74.

Playground

Open in playground — output: construct: Property App\GetAgency::$options is missing required key 'name'

<?php

declare(strict_types=1);

namespace App;

/**
 * @template TOptions of array<string, mixed>
 */
abstract class Command
{
    /**
     * Empty until init() runs.
     *
     * @var TOptions
     */
    protected array $options = [];

    /**
     * @param TOptions $options
     */
    public function init(array $options): static
    {
        $this->options = $options;
        return $this;
    }
}

/**
 * @extends Command<array{name: string}>
 */
final class GetAgency extends Command
{
}

try {
    $command = new GetAgency();
    echo "construct: OK\n";
} catch (\TypeError $e) {
    echo "construct: ", $e->getMessage(), "\n";
}

Actual

construct: Property App\GetAgency::$options is missing required key 'name'

Expected

construct: OK

The default [] is written by the base class, where TOptions is only known by its bound (array<string, mixed>), and [] satisfies that bound. Assignments made after construction (e.g. init(['name' => 'x'])) should of course still be checked against the subclass's binding.

What PHPStan does

Same two classes, PHPStan 2.3.0, level 9: no error on the default value. To show the rule set was active, a deliberate $this->count = -1 on an int<0, max> property in the same base class is reported (assign.propertyType), and nothing else. PHPStan checks a property's default value once, in the declaring class, against the declared type there (a template resolves to its bound). It does not re-check inherited defaults against each subclass's template binding.

Why it matters

This is the standard "command / options bag" pattern: the base class owns $options, each concrete command declares its option shape through @extends, and init() fills the property. With property checks on, every command in our codebase throws at new, so nothing in a module that includes the base class can run. Changing the docblock to TOptions|array{} would be honest about the pre-init() state, but it forces narrowing on every read in every command, which is exactly what PHPStan's model avoids.

Versions

Identical on v0.11.0, v0.11.1, v0.11.2, v0.11.3 and dev-main @ 4e4244e (local project, PHP 8.3, array_validation => 'full'), and in the playground.

Suggested behaviour

Check a property's declared default against the type as seen by the declaring class (template replaced by its bound, mixed when unbound), not against a subclass's binding. Equivalently: skip the default-value check when the declared type is a class template, like PHPStan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions