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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions