From 3a14a3f0ec135fa6960efbb17ac66aa930628df9 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Mon, 16 Mar 2026 09:34:02 +0100 Subject: [PATCH 1/3] Add support for Psl\Type\nullish() in shape types Nullish wraps a type as T|null while keeping the key required, unlike optional() which makes the key absent. Supports both standalone nullish(T) and combined optional(nullish(T)). --- extension.neon | 2 ++ src/Type/TypeShapeReturnTypeExtension.php | 18 ++++++++++++- stubs/NullishType.stub | 17 ++++++++++++ stubs/nullish.stub | 17 ++++++++++++ tests/Type/data/assert.php | 31 +++++++++++++++++++++ tests/Type/data/coerce.php | 29 ++++++++++++++++++++ tests/Type/data/matches.php | 33 +++++++++++++++++++++++ 7 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 stubs/NullishType.stub create mode 100644 stubs/nullish.stub diff --git a/extension.neon b/extension.neon index 10a01cf..8f3a510 100644 --- a/extension.neon +++ b/extension.neon @@ -1,6 +1,8 @@ parameters: stubFiles: - stubs/Option.stub + - stubs/nullish.stub + - stubs/NullishType.stub - stubs/optional.stub - stubs/OptionalType.stub - stubs/Type.stub diff --git a/src/Type/TypeShapeReturnTypeExtension.php b/src/Type/TypeShapeReturnTypeExtension.php index 4163a21..44422b5 100644 --- a/src/Type/TypeShapeReturnTypeExtension.php +++ b/src/Type/TypeShapeReturnTypeExtension.php @@ -12,6 +12,7 @@ use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use Psl\Type\Internal\NullishType; use Psl\Type\Internal\OptionalType; use Psl\Type\TypeInterface; use function count; @@ -55,7 +56,9 @@ private function createResult(ConstantArrayType $arrayType): Type $builder = ConstantArrayTypeBuilder::createEmpty(); foreach ($arrayType->getKeyTypes() as $key) { $valueType = $arrayType->getOffsetValueType($key); - [$type, $optional] = $this->extractOptional($valueType->getTemplateType(TypeInterface::class, 'T')); + $templateType = $valueType->getTemplateType(TypeInterface::class, 'T'); + [$type, $optional] = $this->extractOptional($templateType); + [$type] = $this->extractNullish($type); $builder->setOffsetValueType($key, $type, $optional); } @@ -63,6 +66,19 @@ private function createResult(ConstantArrayType $arrayType): Type return $builder->getArray(); } + /** + * @return array{Type, bool} + */ + private function extractNullish(Type $type): array + { + $nullishType = $type->getTemplateType(NullishType::class, 'T'); + if ($nullishType instanceof ErrorType) { + return [$type, false]; + } + + return [TypeCombinator::addNull($nullishType), false]; + } + /** * @return array{Type, bool} */ diff --git a/stubs/NullishType.stub b/stubs/NullishType.stub new file mode 100644 index 0000000..9b9ca7f --- /dev/null +++ b/stubs/NullishType.stub @@ -0,0 +1,17 @@ + + * + * @internal + */ +final class NullishType extends Type\Type +{ + +} diff --git a/stubs/nullish.stub b/stubs/nullish.stub new file mode 100644 index 0000000..10254f2 --- /dev/null +++ b/stubs/nullish.stub @@ -0,0 +1,17 @@ + $inner_type + * + * @return TypeInterface> + */ +function nullish(TypeInterface $inner_type): TypeInterface +{ + +} diff --git a/tests/Type/data/assert.php b/tests/Type/data/assert.php index 8a5b570..f3a3d52 100644 --- a/tests/Type/data/assert.php +++ b/tests/Type/data/assert.php @@ -29,6 +29,37 @@ public function assertShape(array $a): void assertType('array{name: string, age: int, location?: array{city: string, state: string, country: string}}', $b); } + /** + * @param array $a + */ + public function assertNullishShape(array $a): void + { + $specification = Type\shape([ + 'name' => Type\string(), + 'bio' => Type\nullish(Type\string()), + ]); + + $b = $specification->assert($a); + + assertType('array{name: string, bio: string|null}', $a); + assertType('array{name: string, bio: string|null}', $b); + } + + /** + * @param array $a + */ + public function assertOptionalNullishShape(array $a): void + { + $specification = Type\shape([ + 'bio' => Type\optional(Type\nullish(Type\string())), + ]); + + $b = $specification->assert($a); + + assertType('array{bio?: string|null}', $a); + assertType('array{bio?: string|null}', $b); + } + public function assertInt($i): void { $spec = Type\int(); diff --git a/tests/Type/data/coerce.php b/tests/Type/data/coerce.php index 2d49000..e467cee 100644 --- a/tests/Type/data/coerce.php +++ b/tests/Type/data/coerce.php @@ -29,6 +29,35 @@ public function coerceShape(array $input): void assertType('array', $input); } + /** + * @param array $input + */ + public function coerceNullishShape(array $input): void + { + $specification = Type\shape([ + 'name' => Type\string(), + 'bio' => Type\nullish(Type\string()), + ]); + + $output = $specification->coerce($input); + + assertType('array{name: string, bio: string|null}', $output); + } + + /** + * @param array $input + */ + public function coerceOptionalNullishShape(array $input): void + { + $specification = Type\shape([ + 'bio' => Type\optional(Type\nullish(Type\string())), + ]); + + $output = $specification->coerce($input); + + assertType('array{bio?: string|null}', $output); + } + public function coerceInt($i): void { $spec = Type\int(); diff --git a/tests/Type/data/matches.php b/tests/Type/data/matches.php index 7678eae..4749bca 100644 --- a/tests/Type/data/matches.php +++ b/tests/Type/data/matches.php @@ -30,6 +30,39 @@ public function matchesShape(array $a): void } } + /** + * @param array $a + */ + public function matchesNullishShape(array $a): void + { + $specification = Type\shape([ + 'name' => Type\string(), + 'bio' => Type\nullish(Type\string()), + ]); + + if ($specification->matches($a)) { + assertType('array{name: string, bio: string|null}', $a); + } else { + assertType('array', $a); + } + } + + /** + * @param array $a + */ + public function matchesOptionalNullishShape(array $a): void + { + $specification = Type\shape([ + 'bio' => Type\optional(Type\nullish(Type\string())), + ]); + + if ($specification->matches($a)) { + assertType('array{bio?: string|null}', $a); + } else { + assertType('non-empty-array', $a); + } + } + public function matchesInt($i): void { $spec = Type\int(); From fb110c7462090b5a2488528a68335ba32b0c4629 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Mon, 16 Mar 2026 12:19:23 +0100 Subject: [PATCH 2/3] Fix nullish support for older PSL versions and expand CI matrix - Use string literal instead of NullishType::class to avoid class-not-found errors on PSL versions without NullishType - Move nullish tests to separate fixtures, gated by class_exists() - Add PHP 8.2, 8.3, 8.4, 8.5 to CI matrix --- .github/workflows/build.yml | 12 ++++++ src/Type/TypeShapeReturnTypeExtension.php | 3 +- tests/Type/PslTypeSpecifyingExtensionTest.php | 5 +++ tests/Type/data/assert.php | 31 ------------- tests/Type/data/coerce.php | 29 ------------- tests/Type/data/matches.php | 33 -------------- tests/Type/data/nullishAssert.php | 41 ++++++++++++++++++ tests/Type/data/nullishCoerce.php | 39 +++++++++++++++++ tests/Type/data/nullishMatches.php | 43 +++++++++++++++++++ 9 files changed, 141 insertions(+), 95 deletions(-) create mode 100644 tests/Type/data/nullishAssert.php create mode 100644 tests/Type/data/nullishCoerce.php create mode 100644 tests/Type/data/nullishMatches.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d03ce0..3cd56fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,10 @@ jobs: - "7.4" - "8.0" - "8.1" + - "8.2" + - "8.3" + - "8.4" + - "8.5" steps: - name: "Checkout" @@ -77,6 +81,10 @@ jobs: - "7.4" - "8.0" - "8.1" + - "8.2" + - "8.3" + - "8.4" + - "8.5" dependencies: - "lowest" - "highest" @@ -113,6 +121,10 @@ jobs: - "7.4" - "8.0" - "8.1" + - "8.2" + - "8.3" + - "8.4" + - "8.5" dependencies: - "lowest" - "highest" diff --git a/src/Type/TypeShapeReturnTypeExtension.php b/src/Type/TypeShapeReturnTypeExtension.php index 44422b5..80931bf 100644 --- a/src/Type/TypeShapeReturnTypeExtension.php +++ b/src/Type/TypeShapeReturnTypeExtension.php @@ -12,7 +12,6 @@ use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use Psl\Type\Internal\NullishType; use Psl\Type\Internal\OptionalType; use Psl\Type\TypeInterface; use function count; @@ -71,7 +70,7 @@ private function createResult(ConstantArrayType $arrayType): Type */ private function extractNullish(Type $type): array { - $nullishType = $type->getTemplateType(NullishType::class, 'T'); + $nullishType = $type->getTemplateType('Psl\Type\Internal\NullishType', 'T'); if ($nullishType instanceof ErrorType) { return [$type, false]; } diff --git a/tests/Type/PslTypeSpecifyingExtensionTest.php b/tests/Type/PslTypeSpecifyingExtensionTest.php index cfcb288..1b06833 100644 --- a/tests/Type/PslTypeSpecifyingExtensionTest.php +++ b/tests/Type/PslTypeSpecifyingExtensionTest.php @@ -17,6 +17,11 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/coerce.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/assert.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/matches.php'); + if (class_exists(\Psl\Type\Internal\NullishType::class)) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/nullishCoerce.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/nullishAssert.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/nullishMatches.php'); + } if (InstalledVersions::satisfies(new VersionParser(), 'azjezz/psl', '<2.0.0')) { yield from $this->gatherAssertTypes(__DIR__ . '/data/complexTypev1.php'); } else { diff --git a/tests/Type/data/assert.php b/tests/Type/data/assert.php index f3a3d52..8a5b570 100644 --- a/tests/Type/data/assert.php +++ b/tests/Type/data/assert.php @@ -29,37 +29,6 @@ public function assertShape(array $a): void assertType('array{name: string, age: int, location?: array{city: string, state: string, country: string}}', $b); } - /** - * @param array $a - */ - public function assertNullishShape(array $a): void - { - $specification = Type\shape([ - 'name' => Type\string(), - 'bio' => Type\nullish(Type\string()), - ]); - - $b = $specification->assert($a); - - assertType('array{name: string, bio: string|null}', $a); - assertType('array{name: string, bio: string|null}', $b); - } - - /** - * @param array $a - */ - public function assertOptionalNullishShape(array $a): void - { - $specification = Type\shape([ - 'bio' => Type\optional(Type\nullish(Type\string())), - ]); - - $b = $specification->assert($a); - - assertType('array{bio?: string|null}', $a); - assertType('array{bio?: string|null}', $b); - } - public function assertInt($i): void { $spec = Type\int(); diff --git a/tests/Type/data/coerce.php b/tests/Type/data/coerce.php index e467cee..2d49000 100644 --- a/tests/Type/data/coerce.php +++ b/tests/Type/data/coerce.php @@ -29,35 +29,6 @@ public function coerceShape(array $input): void assertType('array', $input); } - /** - * @param array $input - */ - public function coerceNullishShape(array $input): void - { - $specification = Type\shape([ - 'name' => Type\string(), - 'bio' => Type\nullish(Type\string()), - ]); - - $output = $specification->coerce($input); - - assertType('array{name: string, bio: string|null}', $output); - } - - /** - * @param array $input - */ - public function coerceOptionalNullishShape(array $input): void - { - $specification = Type\shape([ - 'bio' => Type\optional(Type\nullish(Type\string())), - ]); - - $output = $specification->coerce($input); - - assertType('array{bio?: string|null}', $output); - } - public function coerceInt($i): void { $spec = Type\int(); diff --git a/tests/Type/data/matches.php b/tests/Type/data/matches.php index 4749bca..7678eae 100644 --- a/tests/Type/data/matches.php +++ b/tests/Type/data/matches.php @@ -30,39 +30,6 @@ public function matchesShape(array $a): void } } - /** - * @param array $a - */ - public function matchesNullishShape(array $a): void - { - $specification = Type\shape([ - 'name' => Type\string(), - 'bio' => Type\nullish(Type\string()), - ]); - - if ($specification->matches($a)) { - assertType('array{name: string, bio: string|null}', $a); - } else { - assertType('array', $a); - } - } - - /** - * @param array $a - */ - public function matchesOptionalNullishShape(array $a): void - { - $specification = Type\shape([ - 'bio' => Type\optional(Type\nullish(Type\string())), - ]); - - if ($specification->matches($a)) { - assertType('array{bio?: string|null}', $a); - } else { - assertType('non-empty-array', $a); - } - } - public function matchesInt($i): void { $spec = Type\int(); diff --git a/tests/Type/data/nullishAssert.php b/tests/Type/data/nullishAssert.php new file mode 100644 index 0000000..eda8958 --- /dev/null +++ b/tests/Type/data/nullishAssert.php @@ -0,0 +1,41 @@ + $a + */ + public function assertNullishShape(array $a): void + { + $specification = Type\shape([ + 'name' => Type\string(), + 'bio' => Type\nullish(Type\string()), + ]); + + $b = $specification->assert($a); + + assertType('array{name: string, bio: string|null}', $a); + assertType('array{name: string, bio: string|null}', $b); + } + + /** + * @param array $a + */ + public function assertOptionalNullishShape(array $a): void + { + $specification = Type\shape([ + 'bio' => Type\optional(Type\nullish(Type\string())), + ]); + + $b = $specification->assert($a); + + assertType('array{bio?: string|null}', $a); + assertType('array{bio?: string|null}', $b); + } +} diff --git a/tests/Type/data/nullishCoerce.php b/tests/Type/data/nullishCoerce.php new file mode 100644 index 0000000..c7b13e4 --- /dev/null +++ b/tests/Type/data/nullishCoerce.php @@ -0,0 +1,39 @@ + $input + */ + public function coerceNullishShape(array $input): void + { + $specification = Type\shape([ + 'name' => Type\string(), + 'bio' => Type\nullish(Type\string()), + ]); + + $output = $specification->coerce($input); + + assertType('array{name: string, bio: string|null}', $output); + } + + /** + * @param array $input + */ + public function coerceOptionalNullishShape(array $input): void + { + $specification = Type\shape([ + 'bio' => Type\optional(Type\nullish(Type\string())), + ]); + + $output = $specification->coerce($input); + + assertType('array{bio?: string|null}', $output); + } +} diff --git a/tests/Type/data/nullishMatches.php b/tests/Type/data/nullishMatches.php new file mode 100644 index 0000000..2fc6167 --- /dev/null +++ b/tests/Type/data/nullishMatches.php @@ -0,0 +1,43 @@ + $a + */ + public function matchesNullishShape(array $a): void + { + $specification = Type\shape([ + 'name' => Type\string(), + 'bio' => Type\nullish(Type\string()), + ]); + + if ($specification->matches($a)) { + assertType('array{name: string, bio: string|null}', $a); + } else { + assertType('array', $a); + } + } + + /** + * @param array $a + */ + public function matchesOptionalNullishShape(array $a): void + { + $specification = Type\shape([ + 'bio' => Type\optional(Type\nullish(Type\string())), + ]); + + if ($specification->matches($a)) { + assertType('array{bio?: string|null}', $a); + } else { + assertType('non-empty-array', $a); + } + } +} From 95799bdd3d0f0337f1d5c28c73c0f413c4f9baec Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Mon, 16 Mar 2026 20:51:59 +0100 Subject: [PATCH 3/3] Use NullishType::class and fix CS violations - Restore NullishType::class instead of string literal - Add baseline ignores for older PSL versions without NullishType - Fix CS: add use statements for NullishType and class_exists in test --- phpstan-baseline-psl-1.neon | 12 ++++++++++++ src/Type/TypeShapeReturnTypeExtension.php | 3 ++- tests/Type/PslTypeSpecifyingExtensionTest.php | 4 +++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/phpstan-baseline-psl-1.neon b/phpstan-baseline-psl-1.neon index b8b48ee..7c045e0 100644 --- a/phpstan-baseline-psl-1.neon +++ b/phpstan-baseline-psl-1.neon @@ -11,3 +11,15 @@ parameters: identifier: argument.type count: 1 path: src/Option/OptionFilterReturnTypeExtension.php + + - + message: '#^Class Psl\\Type\\Internal\\NullishType not found\.$#' + identifier: class.notFound + count: 1 + path: src/Type/TypeShapeReturnTypeExtension.php + + - + message: '#^Parameter \#1 \$ancestorClassName of method PHPStan\\Type\\Type\:\:getTemplateType\(\) expects class\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Type/TypeShapeReturnTypeExtension.php diff --git a/src/Type/TypeShapeReturnTypeExtension.php b/src/Type/TypeShapeReturnTypeExtension.php index 80931bf..44422b5 100644 --- a/src/Type/TypeShapeReturnTypeExtension.php +++ b/src/Type/TypeShapeReturnTypeExtension.php @@ -12,6 +12,7 @@ use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use Psl\Type\Internal\NullishType; use Psl\Type\Internal\OptionalType; use Psl\Type\TypeInterface; use function count; @@ -70,7 +71,7 @@ private function createResult(ConstantArrayType $arrayType): Type */ private function extractNullish(Type $type): array { - $nullishType = $type->getTemplateType('Psl\Type\Internal\NullishType', 'T'); + $nullishType = $type->getTemplateType(NullishType::class, 'T'); if ($nullishType instanceof ErrorType) { return [$type, false]; } diff --git a/tests/Type/PslTypeSpecifyingExtensionTest.php b/tests/Type/PslTypeSpecifyingExtensionTest.php index 1b06833..85cca59 100644 --- a/tests/Type/PslTypeSpecifyingExtensionTest.php +++ b/tests/Type/PslTypeSpecifyingExtensionTest.php @@ -5,6 +5,8 @@ use Composer\InstalledVersions; use Composer\Semver\VersionParser; use PHPStan\Testing\TypeInferenceTestCase; +use Psl\Type\Internal\NullishType; +use function class_exists; class PslTypeSpecifyingExtensionTest extends TypeInferenceTestCase { @@ -17,7 +19,7 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/coerce.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/assert.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/matches.php'); - if (class_exists(\Psl\Type\Internal\NullishType::class)) { + if (class_exists(NullishType::class)) { yield from $this->gatherAssertTypes(__DIR__ . '/data/nullishCoerce.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/nullishAssert.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/nullishMatches.php');