@@ -116,6 +116,27 @@ function testKeylessImplicitTupleShape(array $tuple): bool
116116 return true ;
117117}
118118
119+ /**
120+ * 1. Sealed Shape (Default)
121+ *
122+ * @param array{id: positive-int, username: non-empty-string} $sealedPayload
123+ */
124+ function testSealedShape (array $ sealedPayload ): bool
125+ {
126+ return true ;
127+ }
128+
129+ /**
130+ * 2. Unsealed Typed Shape
131+ * Requires 'id' to be positive-int, but permits additional string-string pairs
132+ *
133+ * @param array{id: positive-int, ...<string, string>} $unsealedPayload
134+ */
135+ function testUnsealedTypedShape (array $ unsealedPayload ): bool
136+ {
137+ return true ;
138+ }
139+
119140/**
120141 * Helpers for Issue #20 Edge Cases
121142 *
@@ -161,9 +182,8 @@ function testReturnKeylessTuple(bool $valid): array
161182 });
162183
163184 test ('throws TypeError when array contains an invalid class object ' , function () {
164- expect (fn () => testDogArrayParam ([new Dog (), new Car ()]))
165- ->toThrow (TypeError::class)
166- ;
185+ expect (fn () => testDogArrayParam ([new Dog (), new Car ()]))
186+ ->toThrow (TypeError::class);
167187 });
168188});
169189
@@ -174,16 +194,14 @@ function testReturnKeylessTuple(bool $valid): array
174194
175195 test ('throws TypeError when key type is invalid ' , function () {
176196 // Integer key 0 instead of string key
177- expect (fn () => testAssocScoreArrayParam ([0 => 100 ]))
178- ->toThrow (TypeError::class, 'key ' )
179- ;
197+ expect (fn () => testAssocScoreArrayParam ([0 => 100 ]))
198+ ->toThrow (TypeError::class, 'key ' );
180199 });
181200
182201 test ('throws TypeError when value type is invalid ' , function () {
183202 // Negative integer -10 instead of positive-int
184- expect (fn () => testAssocScoreArrayParam (['alice ' => -10 ]))
185- ->toThrow (TypeError::class, "['alice'] " )
186- ;
203+ expect (fn () => testAssocScoreArrayParam (['alice ' => -10 ]))
204+ ->toThrow (TypeError::class, "['alice'] " );
187205 });
188206});
189207
@@ -193,23 +211,20 @@ function testReturnKeylessTuple(bool $valid): array
193211 });
194212
195213 test ('throws TypeError when list contains associative keys ' , function () {
196- expect (fn () => testTagListParam (['tag ' => 'php ' ]))
197- ->toThrow (TypeError::class, 'must be a list ' )
198- ;
214+ expect (fn () => testTagListParam (['tag ' => 'php ' ]))
215+ ->toThrow (TypeError::class, 'must be a list ' );
199216 });
200217
201218 test ('throws TypeError when list contains an empty string ' , function () {
202- expect (fn () => testTagListParam (['php ' , '' ]))
203- ->toThrow (TypeError::class, 'non-empty-string ' )
204- ;
219+ expect (fn () => testTagListParam (['php ' , '' ]))
220+ ->toThrow (TypeError::class, 'non-empty-string ' );
205221 });
206222
207223 test ('accepts valid non-empty list and rejects empty array ' , function () {
208224 expect (testNonEmptyNumberListParam ([1 , 2 , 3 ]))->toBe (3 );
209225
210- expect (fn () => testNonEmptyNumberListParam ([]))
211- ->toThrow (TypeError::class, 'non-empty list ' )
212- ;
226+ expect (fn () => testNonEmptyNumberListParam ([]))
227+ ->toThrow (TypeError::class, 'non-empty list ' );
213228 });
214229});
215230
@@ -228,19 +243,17 @@ function testReturnKeylessTuple(bool $valid): array
228243 'math ' => [100 , -50 ], // -50 is not positive-int
229244 ];
230245
231- expect (fn () => testNestedMatrixParam ($ invalidMatrix ))
232- ->toThrow (TypeError::class)
233- ;
246+ expect (fn () => testNestedMatrixParam ($ invalidMatrix ))
247+ ->toThrow (TypeError::class);
234248 });
235249
236250 test ('throws TypeError when nested list is associative ' , function () {
237251 $ invalidMatrix = [
238252 'math ' => ['score ' => 100 ], // Not a list
239253 ];
240254
241- expect (fn () => testNestedMatrixParam ($ invalidMatrix ))
242- ->toThrow (TypeError::class)
243- ;
255+ expect (fn () => testNestedMatrixParam ($ invalidMatrix ))
256+ ->toThrow (TypeError::class);
244257 });
245258});
246259
@@ -260,9 +273,8 @@ function testReturnKeylessTuple(bool $valid): array
260273 new Producer (new Car ()), // Car is not an Animal
261274 ];
262275
263- expect (fn () => testGenericProducerListParam ($ producers ))
264- ->toThrow (TypeError::class)
265- ;
276+ expect (fn () => testGenericProducerListParam ($ producers ))
277+ ->toThrow (TypeError::class);
266278 });
267279});
268280
@@ -273,14 +285,12 @@ function testReturnKeylessTuple(bool $valid): array
273285
274286 test ('throws TypeError on invalid tuple element ' , function () {
275287 // First item -5 is not positive-int
276- expect (fn () => testTupleShapeParam ([-5 , 'alice ' ]))
277- ->toThrow (TypeError::class, "['0'] " )
278- ;
288+ expect (fn () => testTupleShapeParam ([-5 , 'alice ' ]))
289+ ->toThrow (TypeError::class, "['0'] " );
279290
280291 // Second item '' is not non-empty-string
281- expect (fn () => testTupleShapeParam ([10 , '' ]))
282- ->toThrow (TypeError::class, "['1'] " )
283- ;
292+ expect (fn () => testTupleShapeParam ([10 , '' ]))
293+ ->toThrow (TypeError::class, "['1'] " );
284294 });
285295});
286296
@@ -301,9 +311,8 @@ function testReturnKeylessTuple(bool $valid): array
301311 'invalid_extra ' => 999 , // int given, but string expected by unsealed type
302312 ];
303313
304- expect (fn () => testUnsealedShapeParam ($ payload ))
305- ->toThrow (TypeError::class)
306- ;
314+ expect (fn () => testUnsealedShapeParam ($ payload ))
315+ ->toThrow (TypeError::class);
307316 });
308317});
309318
@@ -316,7 +325,7 @@ function testReturnKeylessTuple(bool $valid): array
316325 });
317326
318327 test ('throws TypeError when any variadic argument violates the array shape ' , function () {
319- expect (fn () => testVariadicArrayShapeParam (
328+ expect (fn () => testVariadicArrayShapeParam (
320329 ['id ' => 1 , 'name ' => 'Alice ' ],
321330 ['id ' => -2 , 'name ' => 'Bob ' ] // -2 is not positive-int
322331 ))->toThrow (TypeError::class);
@@ -340,9 +349,8 @@ function testReturnKeylessTuple(bool $valid): array
340349 'tags ' => ['php ' , '' ], // Empty string violates list<non-empty-string>
341350 ];
342351
343- expect (fn () => testComplexNestedShapeParam ($ payload ))
344- ->toThrow (TypeError::class)
345- ;
352+ expect (fn () => testComplexNestedShapeParam ($ payload ))
353+ ->toThrow (TypeError::class);
346354 });
347355});
348356
@@ -353,12 +361,12 @@ function testReturnKeylessTuple(bool $valid): array
353361 ['status_ok ' , 'code ' => 200 , [1 , 2 , 3 ]]
354362 ))->toBeTrue ();
355363
356- expect (fn () => testLocalTupleAliasParam (
364+ expect (fn () => testLocalTupleAliasParam (
357365 [[10 , -5 ], ['a ' , 'b ' ]],
358366 ['status_ok ' , 'code ' => 200 , [1 , 2 , 3 ]]
359367 ))->toThrow (TypeError::class, "Argument \$payload['0'][1] must be of type positive-int " );
360368
361- expect (fn () => testLocalTupleAliasParam (
369+ expect (fn () => testLocalTupleAliasParam (
362370 [[10 , 20 ], ['a ' , 'b ' ]],
363371 ['status_ok ' , 'code ' => -100 , [1 , 2 , 3 ]]
364372 ))->toThrow (TypeError::class, "Argument \$mixedPayload['code'] must be of type positive-int " );
@@ -367,16 +375,49 @@ function testReturnKeylessTuple(bool $valid): array
367375 test ('resolves keyless tuple shapes imported via @phpstan-import-type ' , function () {
368376 expect (testImportedTupleAliasParam ([[100 , 200 ], 'valid_string ' ]))->toBeTrue ();
369377
370- expect (fn () => testImportedTupleAliasParam ([[100 , 200 ], '' ]))
371- ->toThrow (TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string " )
372- ;
378+ expect (fn () => testImportedTupleAliasParam ([[100 , 200 ], '' ]))
379+ ->toThrow (TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string " );
373380 });
374381
375382 test ('validates keyless tuple shapes returned from functions ' , function () {
376383 expect (testReturnKeylessTuple (true ))->toBe ([[10 , 20 ], 'bundle ' ]);
377384
378- expect (fn () => testReturnKeylessTuple (false ))
379- ->toThrow (TypeError::class, "Return value['0'][1] must be of type positive-int " )
380- ;
385+ expect (fn () => testReturnKeylessTuple (false ))
386+ ->toThrow (TypeError::class, "Return value['0'][1] must be of type positive-int " );
387+ });
388+ });
389+
390+ describe ('Sealed vs Unsealed Array Shapes ' , function () {
391+ describe ('Sealed Shapes (array{id: int}) ' , function () {
392+ test ('accepts exact declared shape keys ' , function () {
393+ expect (testSealedShape (['id ' => 10 , 'username ' => 'Alice ' ]))->toBeTrue ();
394+ });
395+
396+ test ('throws TypeError when sealed shape receives unexpected extra key ' , function () {
397+ expect (fn () => testSealedShape (['id ' => 10 , 'username ' => 'Alice ' , 'extra_key ' => 'bar ' ]))
398+ ->toThrow (TypeError::class, "contains unsealed unexpected key 'extra_key' " );
399+ });
400+ });
401+
402+ describe ('Unsealed Typed Shapes (array{id: int, ...<string, string>}) ' , function () {
403+ test ('accepts required keys plus additional string-string pairs ' , function () {
404+ $ payload = [
405+ 'id ' => 42 ,
406+ 'category ' => 'admin_user ' ,
407+ 'department ' => 'engineering ' ,
408+ ];
409+
410+ expect (testUnsealedTypedShape ($ payload ))->toBeTrue ();
411+ });
412+
413+ test ('throws TypeError when unsealed extra value violates unsealed type contract ' , function () {
414+ $ payload = [
415+ 'id ' => 42 ,
416+ 'code ' => 999 , // 999 is int, but unsealed type requires string value!
417+ ];
418+
419+ expect (fn () => testUnsealedTypedShape ($ payload ))
420+ ->toThrow (TypeError::class, "['code'] must be of type string, int (999) given " );
421+ });
381422 });
382423});
0 commit comments