Skip to content

Ship a PHPStan extension for type narrowing via assert()/check() #1681

@henriquemoody

Description

@henriquemoody

After calling assert() or check() on a validation chain, PHPStan should be able to narrow the type of the asserted value. Currently, PHPStan cannot do this because the method chain goes through @mixin + __callStatic/__call, which prevents PHPStan from tracking type information across the call chain.

Shipping a bundled MethodTypeSpecifyingExtension (auto-discovered via phpstan/extension-installer) would let PHPStan understand that passing validation implies a type guarantee — the same way phpstan-phpunit handles assertInstanceOf(), assertIsString(), etc.

Type narrowing by prefix

Validation has a consistent method naming pattern with prefixes (all*, nullOr*, not*, key*, property*) applied to base validators. Each prefix modifies the semantics and could produce different PHPStan type narrowing:

Base type validators

The core *Type() and instance() methods narrow to a single type:

ValidatorBuilder::stringType()->assert($input);  // $input is string
ValidatorBuilder::arrayType()->assert($input);   // $input is array
ValidatorBuilder::objectType()->assert($input);  // $input is object
ValidatorBuilder::instance(Foo::class)->assert($input); // $input is Foo

Applies to: arrayType, boolType, callableType, floatType, intType, iterableType, nullType, objectType, resourceType, stringType, instance.

all* prefix — array element narrowing

Narrows the input to array<T>:

ValidatorBuilder::allStringType()->assert($input);            // $input is array<string>
ValidatorBuilder::allInstance(Foo::class)->assert($input);     // $input is array<Foo>
ValidatorBuilder::allBoolType()->check($input);                // $input is array<bool>

nullOr* prefix — nullable narrowing

Narrows the input to T|null:

ValidatorBuilder::nullOrStringType()->assert($input);          // $input is string|null
ValidatorBuilder::nullOrInstance(Foo::class)->assert($input);   // $input is Foo|null
ValidatorBuilder::nullOrObjectType()->assert($input);           // $input is object|null

not* prefix — negative narrowing

Excludes a type from the input (useful when the input is already a union):

/** @param int|string $input */
function example(int|string $input): string {
    ValidatorBuilder::notIntType()->assert($input);
    return $input; // $input is string
}

Chained calls

The extension should walk up the method chain to find the relevant type-checking method, regardless of intermediate validators:

ValidatorBuilder::positive()->intType()->assert($input);
ValidatorBuilder::allStringType()->unique()->assert($input);
ValidatorBuilder::objectType()->instance(Foo::class)->assert($input);

Both assert() and check() should be supported since both throw on failure.

Out of scope (for now)

  • key*Type / property*Type: Narrowing types at specific array keys or object properties (e.g., keyStringType('name')) would require PHPStan's offset/property type system and is significantly more complex.
  • length*, min*, max*: These validate numeric ranges/lengths, not types.
  • undefOr*: "Undef" is null|'' in this library, which doesn't map cleanly to a PHPStan type.

Implementation approach

Use MethodTypeSpecifyingExtension + TypeSpecifierAwareExtension:

  1. Walk up the MethodCall/StaticCall chain from assert()/check() to find a type-checking method
  2. Create the corresponding AST expression (Instanceof_, FuncCall('is_string'), etc.) or construct a PHPStan Type directly (for all* which needs ArrayType)
  3. Pass to TypeSpecifier::specifyTypesInCondition() or TypeSpecifier::create() to narrow the type

Ship as extension.neon at the package root, auto-discovered via extra.phpstan.includes in composer.json.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions