Skip to content

Commit 18737bb

Browse files
committed
Add support for key-of<T> and value-of<T> type validation in GenericValidator and update documentation
1 parent f396bb6 commit 18737bb

3 files changed

Lines changed: 403 additions & 3 deletions

File tree

docs/supported-types/arrays-and-shapes.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,146 @@ processTuple([-5, 'success']);
239239
// Throws: TypeError: processTuple(): Argument $tuple['0'] must be of type positive-int
240240
```
241241

242+
Here is the updated documentation with a special, dedicated section for **`key-of<T>`** and **`value-of<T>`** inside `docs/supported-types/arrays-and-shapes.md`.
243+
244+
---
245+
## Key & Value Extraction (`key-of<T>` & `value-of<T>`)
246+
247+
TypePHP supports dynamically restricting function parameters, return types, property writes, or array shape fields to the keys or values of an array constant, an array shape, or a PHP 8.1 Backed Enum using `key-of<T>` and `value-of<T>` type operators.
248+
249+
> **Performance & Visibility:** TypePHP caches array and enum extractions in static memory, guaranteeing **$O(1)$ constant lookup times** during execution. Furthermore, it uses Reflection to safely bypass PHP visibility restrictions, allowing you to reference `private` or `protected` class constants (e.g., `key-of<self::PRIVATE_MAP>`) in docblocks without throwing runtime errors.
250+
251+
| Annotation | Supported Targets `T` | Validation Rule |
252+
| :--- | :--- | :--- |
253+
| **`key-of<T>`** | Array Constant, Array Shape, Enum | Validates that the value matches a valid **array key** or **Enum case name** (e.g., `'Active'`). |
254+
| **`value-of<T>`** | Array Constant, Backed Enum | Validates that the value matches a valid **array value** or **Enum backing value** (e.g., `'active'`). |
255+
256+
---
257+
258+
### 1. Extracting from Class Constants
259+
260+
Extract allowed keys or values directly from `public`, `protected`, or `private` class constant arrays:
261+
262+
```php
263+
<?php
264+
265+
declare(strict_types=1);
266+
267+
namespace App\Database;
268+
269+
class DriverManager
270+
{
271+
/**
272+
* Private constant array
273+
*/
274+
private const DRIVER_MAP = [
275+
'pdo_mysql' => 'PDO\MySQL\Driver',
276+
'pdo_sqlite' => 'PDO\SQLite\Driver',
277+
];
278+
279+
/**
280+
* @param key-of<self::DRIVER_MAP> $driverKey
281+
* @param value-of<self::DRIVER_MAP> $driverClass
282+
*/
283+
public function connect(string $driverKey, string $driverClass): void
284+
{
285+
// ...
286+
}
287+
}
288+
289+
$manager = new DriverManager();
290+
291+
// Valid Call
292+
$manager->connect('pdo_mysql', 'PDO\MySQL\Driver');
293+
294+
// Invalid Driver Key
295+
$manager->connect('pdo_pgsql', 'PDO\MySQL\Driver');
296+
// Throws: TypeError: Argument $driverKey must be a key of App\Database\DriverManager::DRIVER_MAP, string 'pdo_pgsql' given
297+
298+
// Invalid Driver Class Value
299+
$manager->connect('pdo_mysql', 'PDO\PgSQL\Driver');
300+
// Throws: TypeError: Argument $driverClass must be a value of App\Database\DriverManager::DRIVER_MAP
301+
```
302+
303+
---
304+
305+
### 2. Extracting from Enums
306+
307+
For Enums, `key-of<T>` strictly validates case **names**, while `value-of<T>` strictly validates **backing values**:
308+
309+
```php
310+
enum StatusEnum: string
311+
{
312+
case Active = 'active';
313+
case Pending = 'pending';
314+
}
315+
316+
/**
317+
* @param key-of<StatusEnum> $caseName // Expects: 'Active' | 'Pending'
318+
* @param value-of<StatusEnum> $caseValue // Expects: 'active' | 'pending'
319+
*/
320+
function setStatus(string $caseName, string $caseValue): void
321+
{
322+
// ...
323+
}
324+
325+
// Valid Call
326+
setStatus('Active', 'active');
327+
328+
// Invalid Case Name (Passing backing value 'active' where case name 'Active' was expected)
329+
setStatus('active', 'active');
330+
// Throws: TypeError: Argument $caseName must be a key of enum StatusEnum
331+
332+
// Invalid Backing Value
333+
setStatus('Active', 'archived');
334+
// Throws: TypeError: Argument $caseValue must be a value of enum StatusEnum
335+
```
336+
337+
---
338+
339+
### 3. Inline Array Shapes & Type Aliases (`@phpstan-type`)
340+
341+
`key-of<T>` and `value-of<T>` can be used directly on inline array shapes or nested deeply inside `@phpstan-type` / `@psalm-type` aliases:
342+
343+
```php
344+
namespace App\Services;
345+
346+
use App\Database\DriverManager;
347+
348+
/**
349+
* Type alias extracting keys and values from external class constants
350+
*
351+
* @phpstan-type ConnectionParams array{
352+
* driver: key-of<DriverManager::DRIVER_MAP>,
353+
* driverClass?: value-of<DriverManager::DRIVER_MAP>
354+
* }
355+
*/
356+
class ConnectionService
357+
{
358+
/**
359+
* @param ConnectionParams $params
360+
* @param key-of<array{id: int, name: string}> $shapeKey
361+
*/
362+
public function configure(array $params, string $shapeKey): void
363+
{
364+
// ...
365+
}
366+
}
367+
368+
$service = new ConnectionService();
369+
370+
// Valid Call
371+
$service->configure(['driver' => 'pdo_mysql'], 'id');
372+
373+
// Invalid Nested Driver Key inside Type Alias
374+
$service->configure(['driver' => 'pdo_pgsql'], 'id');
375+
// Throws: TypeError: Argument $params['driver'] must be a key of App\Database\DriverManager::DRIVER_MAP
376+
377+
// Invalid Direct Shape Key ('invalid' is neither 'id' nor 'name')
378+
$service->configure(['driver' => 'pdo_mysql'], 'invalid');
379+
// Throws: TypeError: Argument $shapeKey must be a key of the specified array shape
380+
```
381+
242382
---
243383

244384
## Object Shapes (`object{prop: type}` & `stdClass{prop: type}`)

src/Resolver/SpecialTypeResolver.php

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
use PhpParser\Node\Stmt;
88
use PhpParser\ParserFactory;
99
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode;
10+
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode;
11+
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
12+
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode;
1013
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
1114
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
1215
use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode;
@@ -15,6 +18,8 @@
1518
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
1619
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
1720
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
21+
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
22+
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
1823
use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode;
1924
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
2025
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
@@ -107,7 +112,17 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
107112

108113
if ($node instanceof ConstTypeNode) {
109114
if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') {
110-
$resolvedClass = self::resolveFqcn($node->constExpr->className, $ref);
115+
$className = $node->constExpr->className;
116+
$lowerClassName = strtolower($className);
117+
118+
if ($lowerClassName === 'self' && $declaringClass !== null) {
119+
$resolvedClass = $declaringClass;
120+
} elseif ($lowerClassName === 'parent' && $declaringClass !== null) {
121+
$parentClass = get_parent_class($declaringClass);
122+
$resolvedClass = $parentClass !== false ? $parentClass : $className;
123+
} else {
124+
$resolvedClass = self::resolveFqcn($className, $ref);
125+
}
111126

112127
return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name));
113128
}
@@ -129,6 +144,41 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
129144
);
130145
}
131146

147+
if ($node instanceof ArrayShapeNode) {
148+
$items = array_map(function ($item) use ($context, $thisObj) {
149+
return new ArrayShapeItemNode(
150+
$item->keyName,
151+
$item->optional,
152+
self::resolve($item->valueType, $context, $thisObj)
153+
);
154+
}, $node->items);
155+
156+
if ($node->sealed) {
157+
return ArrayShapeNode::createSealed($items, $node->kind);
158+
} else {
159+
$unsealedType = null;
160+
if ($node->unsealedType !== null) {
161+
$unsealedKey = $node->unsealedType->keyType !== null ? self::resolve($node->unsealedType->keyType, $context, $thisObj) : null;
162+
$unsealedValue = self::resolve($node->unsealedType->valueType, $context, $thisObj);
163+
$unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey);
164+
}
165+
166+
return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind);
167+
}
168+
}
169+
170+
if ($node instanceof ObjectShapeNode) {
171+
$items = array_map(function ($item) use ($context, $thisObj) {
172+
return new ObjectShapeItemNode(
173+
$item->keyName,
174+
$item->optional,
175+
self::resolve($item->valueType, $context, $thisObj)
176+
);
177+
}, $node->items);
178+
179+
return new ObjectShapeNode($items);
180+
}
181+
132182
if ($node instanceof CallableTypeNode) {
133183
$resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($context, $thisObj) {
134184
return new CallableTypeParameterNode(
@@ -198,7 +248,14 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode
198248

199249
if ($node instanceof ConstTypeNode) {
200250
if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') {
201-
$resolvedClass = self::resolveFqcnForFile($node->constExpr->className, $file);
251+
$className = $node->constExpr->className;
252+
$lowerClassName = strtolower($className);
253+
254+
if ($lowerClassName === 'self' || $lowerClassName === 'parent') {
255+
$resolvedClass = $className;
256+
} else {
257+
$resolvedClass = self::resolveFqcnForFile($className, $file);
258+
}
202259

203260
return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name));
204261
}
@@ -220,6 +277,41 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode
220277
);
221278
}
222279

280+
if ($node instanceof ArrayShapeNode) {
281+
$items = array_map(function ($item) use ($file) {
282+
return new ArrayShapeItemNode(
283+
$item->keyName,
284+
$item->optional,
285+
self::resolveForFile($item->valueType, $file)
286+
);
287+
}, $node->items);
288+
289+
if ($node->sealed) {
290+
return ArrayShapeNode::createSealed($items, $node->kind);
291+
} else {
292+
$unsealedType = null;
293+
if ($node->unsealedType !== null) {
294+
$unsealedKey = $node->unsealedType->keyType !== null ? self::resolveForFile($node->unsealedType->keyType, $file) : null;
295+
$unsealedValue = self::resolveForFile($node->unsealedType->valueType, $file);
296+
$unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey);
297+
}
298+
299+
return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind);
300+
}
301+
}
302+
303+
if ($node instanceof ObjectShapeNode) {
304+
$items = array_map(function ($item) use ($file) {
305+
return new ObjectShapeItemNode(
306+
$item->keyName,
307+
$item->optional,
308+
self::resolveForFile($item->valueType, $file)
309+
);
310+
}, $node->items);
311+
312+
return new ObjectShapeNode($items);
313+
}
314+
223315
if ($node instanceof CallableTypeNode) {
224316
$resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($file) {
225317
return new CallableTypeParameterNode(

0 commit comments

Comments
 (0)