From c623dbb7ee4634ff11dca7a559af6453a7558113 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 21:55:42 +0800 Subject: [PATCH 1/6] Add fixtures and tests for class-level imported types and trait aliases for baseline test before implementing patches --- .../Imported/ClassUsingTraitWithAlias.php | 10 ++++ .../Types/Imported/DbalKernelPluginLoader.php | 34 ++++++++++++++ .../Types/Imported/KernelPluginLoader.php | 15 ++++++ .../Types/Imported/TraitWithAlias.php | 14 ++++++ .../ImportedTypePropertyTest.php | 46 +++++++++++++++++++ 5 files changed, 119 insertions(+) create mode 100644 tests/Fixtures/Types/Imported/ClassUsingTraitWithAlias.php create mode 100644 tests/Fixtures/Types/Imported/DbalKernelPluginLoader.php create mode 100644 tests/Fixtures/Types/Imported/KernelPluginLoader.php create mode 100644 tests/Fixtures/Types/Imported/TraitWithAlias.php create mode 100644 tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php diff --git a/tests/Fixtures/Types/Imported/ClassUsingTraitWithAlias.php b/tests/Fixtures/Types/Imported/ClassUsingTraitWithAlias.php new file mode 100644 index 0000000..076360b --- /dev/null +++ b/tests/Fixtures/Types/Imported/ClassUsingTraitWithAlias.php @@ -0,0 +1,10 @@ + 3, 'strict' => true]; + + public function load(): void + { + $this->pluginInfos = [ + ['name' => 'SwagPayPal', 'active' => true], + ]; + } + + public function loadBad(): void + { + $this->pluginInfos = [ + ['name' => 'SwagPayPal', 'active' => 'yes'], + ]; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Types/Imported/KernelPluginLoader.php b/tests/Fixtures/Types/Imported/KernelPluginLoader.php new file mode 100644 index 0000000..8906059 --- /dev/null +++ b/tests/Fixtures/Types/Imported/KernelPluginLoader.php @@ -0,0 +1,15 @@ + */ + public array $pluginInfos = []; +} \ No newline at end of file diff --git a/tests/Fixtures/Types/Imported/TraitWithAlias.php b/tests/Fixtures/Types/Imported/TraitWithAlias.php new file mode 100644 index 0000000..80c8a83 --- /dev/null +++ b/tests/Fixtures/Types/Imported/TraitWithAlias.php @@ -0,0 +1,14 @@ + 0, 'y' => 0]; +} \ No newline at end of file diff --git a/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php b/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php new file mode 100644 index 0000000..e748c21 --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php @@ -0,0 +1,46 @@ +load(); + + expect($loader->pluginInfos)->toHaveCount(1); + expect($loader->pluginInfos[0]['name'])->toBe('SwagPayPal'); + }); + + test('fails correctly when the imported array shape is actually violated', function () { + $loader = new DbalKernelPluginLoader(); + + expect(fn() => $loader->loadBad()) + ->toThrow(\TypeError::class, "['active']"); + }); + + test('resolves overridden aliases in child class without breaking parent inheritance', function () { + $loader = new DbalKernelPluginLoader(); + + $loader->config = ['retries' => 5, 'strict' => false]; + expect($loader->config)->toBe(['retries' => 5, 'strict' => false]); + + expect(fn() => $loader->config = ['retries' => -1, 'strict' => false]) + ->toThrow(\TypeError::class, "['retries'] must be of type positive-int"); + }); + + test('resolves aliases defined on traits applied to properties inside the trait', function () { + $instance = new ClassUsingTraitWithAlias(); + + expect($instance->coordinates)->toBe(['x' => 0, 'y' => 0]); + + $instance->coordinates = ['x' => 10, 'y' => 20]; + expect($instance->coordinates['x'])->toBe(10); + + expect(fn() => $instance->coordinates = ['x' => 10, 'y' => 'invalid']) + ->toThrow(\TypeError::class, "['y'] must be of type int"); + }); +}); \ No newline at end of file From 20dfdf6dff8494753c97b76c5c00df6a774679d4 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 21:55:55 +0800 Subject: [PATCH 2/6] Refactor alias extraction logic in ContractParser and DocblockExtractor to prevent overwriting existing aliases --- src/Contract/ContractParser.php | 19 +++++++++---------- src/Contract/DocblockExtractor.php | 14 +++++++++----- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index b8bde46..88bf37a 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -154,7 +154,11 @@ public static function parseProperty(string $className, string $propertyName): ? } $typeNode = $varTags[0]->type; + $aliases = []; + $templates = []; + self::parseClassLevelDocs($declaringClass, $templates, $aliases); + DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass); $typeNode = self::substituteAliases($typeNode, $aliases); @@ -180,14 +184,9 @@ public static function parseClassAliases(string $className): array try { /** @var class-string $className */ $refClass = new \ReflectionClass($className); - $doc = $refClass->getDocComment(); - if ($doc === false) { - return []; - } - - $phpDocNode = DocblockExtractor::parseDocString($doc); $aliases = []; - DocblockExtractor::extractAliases($phpDocNode, $aliases, $refClass); + $templates = []; + self::parseClassLevelDocs($refClass, $templates, $aliases); return $aliases; } catch (\Throwable $e) { @@ -462,7 +461,7 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN if ($node instanceof GenericTypeNode) { $genericType = self::substituteAliases($node->type, $aliases); $genericTypes = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->genericTypes ); @@ -479,14 +478,14 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN if ($node instanceof UnionTypeNode) { return new UnionTypeNode(array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types )); } if ($node instanceof IntersectionTypeNode) { return new IntersectionTypeNode(array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types )); } diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index 31b5d41..b54f973 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -114,15 +114,19 @@ public static function extractAliases( \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref ): void { foreach ($phpDocNode->getTypeAliasTagValues() as $aliasTag) { - $aliases[$aliasTag->alias] = $aliasTag->type; + if (!isset($aliases[$aliasTag->alias])) { + $aliases[$aliasTag->alias] = $aliasTag->type; + } } foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) { $localName = $importTag->importedAs ?? $importTag->importedAlias; - $fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); - $resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias); - if ($resolvedType !== null) { - $aliases[$localName] = $resolvedType; + if (!isset($aliases[$localName])) { + $fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); + $resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias); + if ($resolvedType !== null) { + $aliases[$localName] = $resolvedType; + } } } } From 2164919832ab38a7ba6cbeaf37e02cae73780695 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 22:09:26 +0800 Subject: [PATCH 3/6] Add UnionErrorBubblingTest to validate deep union error handling --- .../UnionErrorBubblingTest.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php diff --git a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php new file mode 100644 index 0000000..a27f01b --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php @@ -0,0 +1,41 @@ +}|null $payload + */ +function testDeepUnionError(array|null $payload): bool +{ + return true; +} + +/** + * @return array{name: string, args: list}|null + */ +function testAttributeCompilerSim(): ?array +{ + return [ + 'name' => 'Field', + 'args' => ['column', 'property', new \stdClass()], + ]; +} + +describe('Union Deep Error Bubbling', function () { + test('surfaces deep array shape error instead of generic union error', function () { + expect(fn() => testDeepUnionError(['id' => 10, 'tags' => ['hello', false]])) + ->toThrow(\TypeError::class, "Argument \$payload['tags'][1] must be of type (string | int)"); + }); + + test('surfaces deep return shape error mimicking AttributeEntityCompiler', function () { + expect(fn() => testAttributeCompilerSim()) + ->toThrow(\TypeError::class, "Return value['args'][2] must be of type (string | int | false)"); + }); + + test('surfaces missing key error from array shape inside union', function () { + expect(fn() => testDeepUnionError(['id' => 10])) + ->toThrow(\TypeError::class, "Argument \$payload is missing required key 'tags'"); + }); +}); \ No newline at end of file From 92a4b15ce126fc9e27353e860e2f30fa188d3723 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 22:09:34 +0800 Subject: [PATCH 4/6] Enhance UnionValidator to collect and return deep validation errors for union types --- src/Validator/UnionValidator.php | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Validator/UnionValidator.php b/src/Validator/UnionValidator.php index 101cbbc..59af4d1 100644 --- a/src/Validator/UnionValidator.php +++ b/src/Validator/UnionValidator.php @@ -20,12 +20,34 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali /** @var UnionTypeNode $unionNode */ $unionNode = $node; + $deepErrors = []; + foreach ($unionNode->types as $type) { - if ($registry->validate($value, $type, $context) === null) { + $err = $registry->validate($value, $type, $context); + if ($err === null) { return null; } + + $msg = $err->getMessage(); + + if ( + str_starts_with($msg, $context . '[') || + str_starts_with($msg, $context . '->') || + str_starts_with($msg, $context . ' is missing required') || + str_starts_with($msg, $context . ' contains unsealed') || + str_starts_with($msg, $context . ' property') || + str_starts_with($msg, $context . ' key') || + str_starts_with($msg, $context . ' value') || + str_starts_with($msg, $context . ' extra key') + ) { + $deepErrors[] = $err; + } + } + + if (\count($deepErrors) > 0) { + return $deepErrors[0]; } return ErrorFactory::createError($context . ' must be of type ' . $unionNode . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); } -} +} \ No newline at end of file From 62088974416909659412ac04c0cf08bfa57cbb1d Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 22:15:33 +0800 Subject: [PATCH 5/6] Add more test for validation union error bubling for array and object shapes --- .../UnionErrorBubblingTest.php | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php index a27f01b..c9f63d9 100644 --- a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php +++ b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php @@ -2,12 +2,10 @@ declare(strict_types=1); -namespace TypePHP\Tests\TypeChecking\ArraysAndShapes; - /** * @param array{id: int, tags: list}|null $payload */ -function testDeepUnionError(array|null $payload): bool +function testDeepUnionError(mixed $payload): bool { return true; } @@ -23,6 +21,30 @@ function testAttributeCompilerSim(): ?array ]; } +/** + * @param object{id: positive-int, profile: object{name: non-empty-string}}|null $user + */ +function testDeepObjectShapeUnion(mixed $user): bool +{ + return true; +} + +/** + * @param (array{type: 'A', data: array{score: positive-int}} | array{type: 'B', data: array{code: non-empty-string}})|null $discriminated + */ +function testDiscriminatedUnionDeepError(mixed $discriminated): bool +{ + return true; +} + +class PropertyUnionFixture +{ + /** + * @var array{config: array{enabled: bool}}|null + */ + public ?array $settings = null; +} + describe('Union Deep Error Bubbling', function () { test('surfaces deep array shape error instead of generic union error', function () { expect(fn() => testDeepUnionError(['id' => 10, 'tags' => ['hello', false]])) @@ -38,4 +60,36 @@ function testAttributeCompilerSim(): ?array expect(fn() => testDeepUnionError(['id' => 10])) ->toThrow(\TypeError::class, "Argument \$payload is missing required key 'tags'"); }); + + test('surfaces deep object shape error inside union', function () { + $user = new \stdClass(); + $user->id = 10; + $user->profile = new \stdClass(); + $user->profile->name = ''; + + expect(fn() => testDeepObjectShapeUnion($user)) + ->toThrow(\TypeError::class, "Argument \$user->profile->name must be of type non-empty-string"); + }); + + test('surfaces deep error in nested discriminated union shape', function () { + $payload = [ + 'type' => 'A', + 'data' => ['score' => -5], + ]; + + expect(fn() => testDiscriminatedUnionDeepError($payload)) + ->toThrow(\TypeError::class, "['score'] must be of type positive-int"); + }); + + test('surfaces deep error on class property with union shape', function () { + $fixture = new PropertyUnionFixture(); + + expect(fn() => $fixture->settings = ['config' => ['enabled' => 'not_a_bool']]) + ->toThrow(\TypeError::class, "Property PropertyUnionFixture::\$settings['config']['enabled'] must be of type bool"); + }); + + test('falls back gracefully to generic union error when no deep branch matches structure', function () { + expect(fn() => testDeepUnionError('string_instead_of_array')) + ->toThrow(\TypeError::class, "must be of type (array{id: int, tags: list<(string | int)>} | null)"); + }); }); \ No newline at end of file From 6e969bbd27c9f12b6f6274a9797a6fcb585baffd Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 22:23:41 +0800 Subject: [PATCH 6/6] Add tests for missing and uninitialized properties in object shape unions using anonymous classes --- .../UnionErrorBubblingTest.php | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php index c9f63d9..5454634 100644 --- a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php +++ b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php @@ -29,6 +29,22 @@ function testDeepObjectShapeUnion(mixed $user): bool return true; } +/** + * @param object{id: int, role: string}|null $data + */ +function testMissingObjectPropertyUnion(mixed $data): bool +{ + return true; +} + +/** + * @param object{name: string}|null $data + */ +function testUninitializedObjectPropertyUnion(mixed $data): bool +{ + return true; +} + /** * @param (array{type: 'A', data: array{score: positive-int}} | array{type: 'B', data: array{code: non-empty-string}})|null $discriminated */ @@ -61,20 +77,44 @@ class PropertyUnionFixture ->toThrow(\TypeError::class, "Argument \$payload is missing required key 'tags'"); }); - test('surfaces deep object shape error inside union', function () { - $user = new \stdClass(); - $user->id = 10; - $user->profile = new \stdClass(); - $user->profile->name = ''; + test('surfaces deep object shape error inside union using anonymous class', function () { + $user = new class { + public int $id = 10; + public object $profile; + + public function __construct() { + $this->profile = new class { + public string $name = ''; + }; + } + }; expect(fn() => testDeepObjectShapeUnion($user)) ->toThrow(\TypeError::class, "Argument \$user->profile->name must be of type non-empty-string"); }); + test('surfaces missing property error on object shape inside union using anonymous class', function () { + $obj = new class { + public int $id = 10; + }; + + expect(fn() => testMissingObjectPropertyUnion($obj)) + ->toThrow(\TypeError::class, "Argument \$data is missing required property 'role'"); + }); + + test('surfaces uninitialized property error on object shape inside union using anonymous class', function () { + $obj = new class { + public string $name; + }; + + expect(fn() => testUninitializedObjectPropertyUnion($obj)) + ->toThrow(\TypeError::class, "Argument \$data property 'name' is uninitialized"); + }); + test('surfaces deep error in nested discriminated union shape', function () { $payload = [ 'type' => 'A', - 'data' => ['score' => -5], + 'data' => ['score' => -5], ]; expect(fn() => testDiscriminatedUnionDeepError($payload))