From 7338608e838c28c4522a2c8ba449fe07e109967c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 12 Aug 2026 20:08:24 +0800 Subject: [PATCH 1/2] Add tuple type handling and tests for keyless tuples in ArrayAndListTypesTest --- tests/Fixtures/Types/GlobalTypes.php | 3 +- .../ArraysAndShapes/ArrayAndListTypesTest.php | 83 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/tests/Fixtures/Types/GlobalTypes.php b/tests/Fixtures/Types/GlobalTypes.php index d65779c..258c128 100644 --- a/tests/Fixtures/Types/GlobalTypes.php +++ b/tests/Fixtures/Types/GlobalTypes.php @@ -6,7 +6,8 @@ /** * @phpstan-type SharedShape array{id: positive-int, name: non-empty-string} + * @phpstan-type SharedTupleShape array{list, non-empty-string} */ class GlobalTypes { -} +} \ No newline at end of file diff --git a/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php index 8ac0053..34105bb 100644 --- a/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php +++ b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php @@ -106,6 +106,54 @@ function testComplexNestedShapeParam(array $data): bool return true; } +/** + * Test function for Issue #20: Implicit keyless tuple array shapes + * + * @param array{list, list} $tuple + */ +function testKeylessImplicitTupleShape(array $tuple): bool +{ + return true; +} + +/** + * Helpers for Issue #20 Edge Cases + * + * @phpstan-type LocalTupleAlias array{list, list} + * @phpstan-type MixedTupleShape array{non-empty-string, code: positive-int, list} + * @phpstan-import-type SharedTupleShape from \TypePHP\Tests\Fixtures\Types\GlobalTypes as ImportedTuple + * + * @param LocalTupleAlias $payload + * @param MixedTupleShape $mixedPayload + */ +function testLocalTupleAliasParam(array $payload, array $mixedPayload): bool +{ + return true; +} + +/** + * @phpstan-import-type SharedTupleShape from \TypePHP\Tests\Fixtures\Types\GlobalTypes as ImportedTuple + * + * @param ImportedTuple $tuple + */ +function testImportedTupleAliasParam(array $tuple): bool +{ + return true; +} + +/** + * @return array{list, non-empty-string} + */ +function testReturnKeylessTuple(bool $valid): array +{ + if (! $valid) { + return [[10, -5], 'bundle']; + } + + return [[10, 20], 'bundle']; +} + + describe('Class Object Arrays (Dog[])', function () { test('accepts array of matching class instances', function () { expect(testDogArrayParam([new Dog(), new Dog()]))->toBe(2); @@ -297,3 +345,38 @@ function testComplexNestedShapeParam(array $data): bool ; }); }); + +describe('Issue #20 Edge Cases: Keyless Tuples in Type Aliases, Returns, and Mixed Keys', function () { + test('resolves keyless tuple shapes defined inside local @phpstan-type aliases', function () { + expect(testLocalTupleAliasParam( + [[10, 20], ['a', 'b']], + ['status_ok', 'code' => 200, [1, 2, 3]] + ))->toBeTrue(); + + expect(fn () => testLocalTupleAliasParam( + [[10, -5], ['a', 'b']], + ['status_ok', 'code' => 200, [1, 2, 3]] + ))->toThrow(TypeError::class, "Argument \$payload['0'][1] must be of type positive-int"); + + expect(fn () => testLocalTupleAliasParam( + [[10, 20], ['a', 'b']], + ['status_ok', 'code' => -100, [1, 2, 3]] + ))->toThrow(TypeError::class, "Argument \$mixedPayload['code'] must be of type positive-int"); + }); + + test('resolves keyless tuple shapes imported via @phpstan-import-type', function () { + expect(testImportedTupleAliasParam([[100, 200], 'valid_string']))->toBeTrue(); + + expect(fn () => testImportedTupleAliasParam([[100, 200], ''])) + ->toThrow(TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string") + ; + }); + + test('validates keyless tuple shapes returned from functions', function () { + expect(testReturnKeylessTuple(true))->toBe([[10, 20], 'bundle']); + + expect(fn () => testReturnKeylessTuple(false)) + ->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int") + ; + }); +}); \ No newline at end of file From a95bb1e8da5f94f071f1d478a477fffa7ebcd7a4 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 12 Aug 2026 20:08:36 +0800 Subject: [PATCH 2/2] Enhance ArrayShapeValidator to support tuple shapes and improve key handling --- src/Validator/ArrayShapeValidator.php | 38 +++++++++++++++++---------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/Validator/ArrayShapeValidator.php b/src/Validator/ArrayShapeValidator.php index 06222a1..95f379a 100644 --- a/src/Validator/ArrayShapeValidator.php +++ b/src/Validator/ArrayShapeValidator.php @@ -4,15 +4,17 @@ namespace TypePHP\Validator; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use TypePHP\Internal\ErrorFactory; use TypePHP\Internal\ErrorMessage; use TypePHP\Internal\TypeFormatter; /** - * @internal Class for validating array shapes like array<1:string,2:int>. + * @internal Class for validating array shapes and tuple shapes like array{0: string, 1: int} or array{string, int}. */ final class ArrayShapeValidator implements TypeValidatorInterface { @@ -24,30 +26,38 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali /** @var ArrayShapeNode $node */ $knownKeys = []; + $nextAutoIndex = 0; foreach ($node->items as $item) { $key = null; + if ($item->keyName instanceof ConstExprStringNode) { $key = $item->keyName->value; + } elseif ($item->keyName instanceof ConstExprIntegerNode) { + $key = (int) $item->keyName->value; + $nextAutoIndex = max($nextAutoIndex, $key + 1); + } elseif ($item->keyName instanceof IdentifierTypeNode) { + $key = $item->keyName->name; } elseif ($item->keyName !== null) { $key = (string) $item->keyName; + } else { + $key = $nextAutoIndex; + $nextAutoIndex++; } - if ($key !== null) { - $knownKeys[$key] = true; - - if (! \array_key_exists($key, $value)) { - if (! $item->optional) { - return ErrorFactory::createError($context . " is missing required key '$key'"); - } + $knownKeys[$key] = true; - continue; + if (! \array_key_exists($key, $value)) { + if (! $item->optional) { + return ErrorFactory::createError($context . " is missing required key '$key'"); } - $err = $registry->validate($value[$key], $item->valueType, $context . "['" . $key . "']"); - if ($err !== null) { - return $err; - } + continue; + } + + $err = $registry->validate($value[$key], $item->valueType, $context . "['" . $key . "']"); + if ($err !== null) { + return $err; } } @@ -81,4 +91,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } -} +} \ No newline at end of file