diff --git a/README.md b/README.md
index 3b0cb52..2052ed8 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,10 @@
TypePHP
+
+ No transpilation. No build steps. No C-extensions.
+ Drop TypePHP into your existing codebase and let your DocBlocks scream when types fail.
+
+
@@ -11,7 +16,7 @@
------
-TypePHP is the first pure-PHP library that transparently enforces extended PHPDoc type contracts (generics, array shapes, scalar refinements, and callables) at runtime during execution, without introducing any new syntax or requiring C-extensions.
+TypePHP is a transparent, pure-PHP runtime type checker. You don't have to refactor a single line of your codebase, setup complex build toolchains, or compile C-extensions and simply run your existing code, and TypePHP will enforce your extended PHPDoc contracts (generics, array shapes, `key-of`/`value-of` extractions, and scalar refinements) dynamically at runtime.
**[Read the full TypePHP documentation »](https://typephp-php.github.io/typephp/)**
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 7dc502b..68bc05b 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -9,8 +9,9 @@ export default defineConfig({
nav: [
{ text: 'Home', link: '/' },
{ text: 'Documentation', link: '/getting-started/installation' },
- { text: 'Architecture', link: '/architecture/how-it-works' },
- { text: 'CLI', link: '/production/cache-commands' },
+ { text: 'Generics', link: '/generics/generics-and-bounds' },
+ { text: 'CLI', link: '/getting-started/cli-commands' },
+ { text: 'FAQ', link: '/troubleshooting' },
{ text: 'GitHub', link: 'https://github.com/typephp-php/typephp' }
],
sidebar: [
@@ -20,52 +21,57 @@ export default defineConfig({
{ text: 'Installation', link: '/getting-started/installation' },
{ text: 'Quick Start', link: '/getting-started/quick-start' },
{ text: 'Configuration', link: '/getting-started/configuration' },
+ { text: 'CLI Commands', link: '/getting-started/cli-commands' },
]
},
{
- text: 'Architecture',
- items: [
- { text: 'How It Works', link: '/architecture/how-it-works' },
- ]
- },
- {
- text: 'Core Concepts',
+ text: 'Enforcement Boundaries',
items: [
{ text: 'Function Contracts', link: '/core-concepts/function-contracts' },
- { text: 'Inline Variables', link: '/core-concepts/inline-variables' },
{ text: 'Property Validation', link: '/core-concepts/property-validation' },
- { text: 'Generics & Bounds', link: '/core-concepts/generics-and-bounds' },
- { text: 'Type Aliases', link: '/core-concepts/type-aliases' },
+ { text: 'Inline Variables', link: '/core-concepts/inline-variables' },
]
},
{
- text: 'Supported Types',
+ text: 'Type Reference',
items: [
{ text: 'Primitives & Scalars', link: '/supported-types/primitives-and-scalars' },
{ text: 'Arrays & Shapes', link: '/supported-types/arrays-and-shapes' },
{ text: 'Callables & Closures', link: '/supported-types/callables-and-closures' },
{ text: 'Iterators & Generators', link: '/supported-types/iterators-and-generators' },
{ text: 'Unions, Intersections & Conditionals', link: '/supported-types/unions-intersections-and-conditionals' },
+ { text: 'Type Aliases', link: '/supported-types/type-aliases' },
+ ]
+ },
+ {
+ text: 'Runtime Generics',
+ items: [
+ { text: 'Generics & Bounds', link: '/generics/generics-and-bounds' },
]
},
{
- text: 'Advanced Features',
+ text: 'Advanced & Architecture',
items: [
+ { text: 'How It Works', link: '/advanced/how-it-works' },
{ text: 'Liskov & Inheritance', link: '/advanced/liskov-and-inheritance' },
{ text: 'Vendor Isolation', link: '/advanced/vendor-and-path-filtering' },
{ text: 'Ignore Annotations', link: '/advanced/ignore-annotations' },
{ text: 'Extensions', link: '/advanced/extensions' },
{ text: 'Exception Handling', link: '/advanced/exception-handling' },
- { text: 'Troubleshooting & FAQ', link: '/advanced/troubleshooting' }
]
},
{
- text: 'Production & Performance',
+ text: 'Production & Operations',
items: [
{ text: 'Production Readiness', link: '/production/production-readiness' },
- { text: 'Cache CLI Commands', link: '/production/cache-commands' },
{ text: 'Performance Considerations', link: '/production/performance-considerations' },
]
+ },
+ {
+ text: 'Help & Support',
+ items: [
+ { text: 'Troubleshooting & FAQ', link: '/troubleshooting' },
+ ]
}
],
socialLinks: [
diff --git a/docs/architecture/how-it-works.md b/docs/advanced/how-it-works.md
similarity index 100%
rename from docs/architecture/how-it-works.md
rename to docs/advanced/how-it-works.md
diff --git a/docs/advanced/liskov-and-inheritance.md b/docs/advanced/liskov-and-inheritance.md
index 3c110fc..add5fe3 100644
--- a/docs/advanced/liskov-and-inheritance.md
+++ b/docs/advanced/liskov-and-inheritance.md
@@ -292,35 +292,42 @@ $service->update(10, 'Charlie');
---
-## Parameter Renaming ($id $\rightarrow$ $userId$)
+## Parameter Renaming ($id → $userId) & Position Shifts
-PHP permits child classes to rename parameters when implementing an interface or extending a class. TypePHP maps inherited parameter contracts by **index position** (0, 1, 2...) rather than parameter name:
+When a child class or attribute constructor overrides a parent method, parameter positions or parameter names may shift. TypePHP resolves parameter contract inheritance using **Name-First Resolution**:
+
+1. **Name Matching:** If a parameter name in the child method matches a parameter name in the parent class (e.g. `$api`), the parent's contract is inherited by that parameter regardless of its position index in the child.
+2. **Position Fallback:** If a parameter is renamed in the child class (e.g., `$id` $\rightarrow$ `$userId`), TypePHP falls back to matching by position index.
```php
-interface UserApiInterface
+class BaseField
{
/**
- * Interface uses parameter name $id
+ * Parent constructor has $api at position #1
*
- * @param positive-int $id
+ * @param string $type
+ * @param bool|array{admin-api: bool} $api
*/
- public function find(int $id): bool;
+ public function __construct(string $type, bool|array $api = false) {}
}
-class UserApi implements UserApiInterface
+class OneToManyRelation extends BaseField
{
- // Child renames parameter $id to $userId
- public function find(int $userId): bool
- {
- return true;
+ /**
+ * Child inserts $entity, $ref, $onDelete BEFORE $api (position shift!)
+ */
+ public function __construct(
+ string $entity,
+ string $ref,
+ OnDeleteOption $onDelete = OnDeleteOption::NO_ACTION,
+ bool|array $api = false
+ ) {
+ parent::__construct('one-to-many', $api);
}
}
-$api = new UserApi();
-
-// $userId = -50 is checked at index 0 against interface's @param positive-int $id!
-$api->find(-50);
-// Throws: TypeError: UserApi::find(): Argument $userId must be of type positive-int
+// $onDelete (position #2 in child) is NOT overwritten by $api's type (position #1 in parent)!
+$attr = new OneToManyRelation('unit', 'unit_id', OnDeleteOption::CASCADE, true);
```
---
diff --git a/docs/core-concepts/type-aliases.md b/docs/advanced/type-aliases.md
similarity index 100%
rename from docs/core-concepts/type-aliases.md
rename to docs/advanced/type-aliases.md
diff --git a/docs/core-concepts/function-contracts.md b/docs/core-concepts/function-contracts.md
index ec32f44..543da06 100644
--- a/docs/core-concepts/function-contracts.md
+++ b/docs/core-concepts/function-contracts.md
@@ -35,6 +35,33 @@ registerUser(-5, 'Alice', 'admin');
> **Execution Order Note:** Native PHP type hints (e.g., `int $id`, `string $username`) are evaluated by PHP's C-engine *before* function execution begins. TypePHP's extended PHPDoc contracts (e.g., `positive-int`, `non-empty-string`) execute at the very start of the function/method body. If a native type hint fails, PHP throws its native `TypeError` before TypePHP's guard rails run.
+---
+## PHP 8.0+ Named Arguments
+
+TypePHP natively supports PHP 8.0+ Named Arguments. Because parameter contracts are mapped by parameter name rather than argument position index, you can pass named arguments in any order, and TypePHP will accurately validate each parameter:
+
+```php
+ $age
+ */
+function registerUser(int $id, string $username, int $age): void
+{
+ // ...
+}
+
+// Valid Call: Arguments passed in completely reversed/swapped order
+registerUser(age: 25, username: 'Alice', id: 42);
+
+// Invalid Call: $id (-5) passed as 3rd named argument
+registerUser(age: 25, username: 'Alice', id: -5);
+// Throws: TypeError: registerUser(): Argument $id must be of type positive-int, negative int (-5) given
+```
---
## Class Methods (Instance & Static)
diff --git a/docs/core-concepts/generics-and-bounds.md b/docs/generics/generics-and-bounds.md
similarity index 100%
rename from docs/core-concepts/generics-and-bounds.md
rename to docs/generics/generics-and-bounds.md
diff --git a/docs/production/cache-commands.md b/docs/getting-started/cli-commands.md
similarity index 100%
rename from docs/production/cache-commands.md
rename to docs/getting-started/cli-commands.md
diff --git a/docs/index.md b/docs/index.md
index 7a23df7..29f8b6a 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,7 +4,7 @@ layout: home
hero:
name: "TypePHP"
text: "Transparent Runtime Type Enforcement"
- tagline: "The first pure PHP library to enforce DocBlock types at runtime transparently without introducing any new syntax. Validates generics, array shapes, and advanced type contracts during execution."
+ tagline: "No transpilation. No build steps. No C-extensions. Just 100% pure PHP that makes your existing DocBlocks scream the moment types fail."
actions:
- theme: brand
text: "Get Started →"
@@ -16,17 +16,52 @@ hero:
features:
- title: "Zero Production Overhead"
details: "Install as a development dependency to enforce strict types during local testing and CI/CD pipelines, guaranteeing absolute zero performance cost in live production environments."
+ - title: "No Transpilation or C-Extensions"
+ details: "Operates 100% in pure PHP user-land using native stream wrappers and AST transformations. No build scripts, Node.js tools, or C-extensions required."
- title: "True Runtime Generics"
details: "Binds generic template types to specific object instances dynamically using native WeakMap memory tracking."
- - title: "Typed Arrays & Shapes"
- details: "Deeply validates sequential lists, typed class arrays, and strict associative array shape structures right out of the box."
- - title: "PHP 8.4 Support"
- details: "Native support for intercepting and validating PHP 8.4 Property Hooks (get/set) and Asymmetric Visibility (public private(set))."
+ - title: "Arrays, Shapes & Extractions"
+ details: "Deeply validates sequential lists, typed arrays, array shapes, and key-of / value-of constant extractions out of the box."
---
+::: tip Pure PHP • Zero Transpilation • Zero Build Steps
+**You don't have to change a single line of code, and you don't need a compilation build toolchain.** TypePHP operates entirely in native PHP user-land and no custom PHP binaries, C-extensions, or Node.js transpilers needed. Drop TypePHP into your existing project, run your code, and your DocBlocks will instantly start screaming at runtime when dynamic data violates a type contract.
+:::
+
## See It In Action
-TypePHP is the first pure PHP library that operates entirely in user-land using native stream wrappers and AST transformations. Because it requires no C-extensions or FFI, you can drop it into any PHP 8.1+ project effortlessly. It parses your standard PHPDoc annotations and enforces them the moment your code runs.
+TypePHP operates entirely in user-land using native stream wrappers and AST transformations. Because it requires no C-extensions or FFI, you can drop it into any PHP 8.1+ project or web framework effortlessly. It reads your existing PHPDoc annotations and enforces them the moment your code runs.
+
+### Real-World Framework Guard Rails (Laravel / Symfony)
+Prevent dynamic data bugs from leaking into database queries or API responses:
+
+```php
+namespace App\Models;
+
+use App\Enums\Role;
+use Illuminate\Database\Eloquent\Model;
+
+class User extends Model
+{
+ /**
+ * @return list
+ */
+ public function assignableRoles(): array
+ {
+ if ($this->isSuperAdmin()) {
+ // Bug! Returns an array of Role Enum instances instead of integers:
+ return Role::cases();
+ }
+
+ return [Role::STAFF->value];
+ }
+}
+
+// Executing $user->assignableRoles() throws:
+// TypePHP\Exception\TypeError: User::assignableRoles(): Return value[0] must be of type int, App\Enums\Role returned
+```
+
+---
### True Runtime Generics
Define generic templates and TypePHP will track their state in memory per object instance:
@@ -51,68 +86,53 @@ $users->add(new Product('SKU-100'));
// Throws TypeError: Argument $item (template T = User) must be of type User, Product given
```
-### Array Shapes & Typed Arrays
-Enforce strict associative array structures and collections of specific objects:
-
-```php
-/**
- * @param array{status: 'active'|'pending', tags: list} $options
- * @param User[] $collaborators
- */
-function processBatch(array $options, array $collaborators): void
-{
- // ...
-}
+---
-processBatch(
- options: ['status' => 'active', 'tags' => ['php', 'types']],
- collaborators: [new User(), new User()]
-); // Valid
+### Array Shapes & Key/Value Extractions
+Enforce strict associative array structures and constant extractions:
-processBatch(
- options: ['status' => 'archived', 'tags' => ['php']],
- collaborators: []
-);
-// Throws TypeError: Argument $options['status'] must be of type ('active' | 'pending')
-```
+```php
+namespace App\Services;
-### Scalar Refinements & Function Boundaries
-Catch invalid parameters before your function executes, and invalid return values before they leak out:
+use App\Database\DriverManager;
-```php
/**
- * @param positive-int $id
- * @return non-empty-string
+ * @phpstan-type ConnectionParams array{
+ * driver: key-of,
+ * driverClass?: value-of
+ * }
*/
-function generateUserToken(int $id): string
+class DatabaseService
{
- return ""; // Throws TypeError: Return value must be of type non-empty-string
+ /**
+ * @param ConnectionParams $params
+ */
+ public function connect(array $params): void
+ {
+ // ...
+ }
}
-generateUserToken(-5);
-// Throws TypeError: Argument $id must be of type positive-int, negative int (-5) given
+$service = new DatabaseService();
+
+$service->connect(['driver' => 'pdo_mysql']); // Valid
+
+$service->connect(['driver' => 'pdo_invalid']);
+// Throws TypeError: Argument $params['driver'] must be a key of DriverManager::DRIVER_MAP
```
---
-## Precise Stack Trace & Error Reporting
+## Precise Call-Site Trace Attribution
-TypePHP injects single-line guard rails without shifting your source file line numbers.
+A common problem with AST code injection is that adding new statements pushes subsequent code down, causing line numbers in stack traces to drift out of sync.
-When an inline variable or type contract fails, framework error handlers and test runners (like Pest, PHPUnit, and Whoops) point **directly to the exact line number** where the invalid assignment or argument occurred in your application code:
+TypePHP solves this with **Zero Line-Drift Formatting**. Injected guard rails are squashed onto single lines and appended directly to existing code blocks. **Line numbers in your source files remain 100% identical before and after transformation.**
-```
- FAILED Tests\SomeTest > test
-
- TypeError: Variable $typeArray[3] must be of type int, string '1' given
-
- at tests/SomeTest.php:7
- 3| declare(strict_types=1);
- 4|
- 5| test('test', function () {
- 6| /** @var array */
- ➜ 7| $typeArray = [1, 2, 3, '1'];
- 8|
- 9| expect($typeArray)->toBeArray();
- 10| });
-```
+When a type contract fails, web exception handlers (**Laravel Ignition, Whoops, Symfony ErrorHandler**) and CLI test runners (**Pest, PHPUnit**) point **directly to the exact line number** where the invalid assignment or return value occurred in your application code:
+
+### Web Framework Trace (Laravel Ignition)
+
+
+### CLI Test Runner Trace (Pest PHP)
+
diff --git a/docs/public/laravel-error-screen.png b/docs/public/laravel-error-screen.png
new file mode 100644
index 0000000..3b54539
Binary files /dev/null and b/docs/public/laravel-error-screen.png differ
diff --git a/docs/public/pest-error-screen.png b/docs/public/pest-error-screen.png
new file mode 100644
index 0000000..a0de081
Binary files /dev/null and b/docs/public/pest-error-screen.png differ
diff --git a/docs/supported-types/arrays-and-shapes.md b/docs/supported-types/arrays-and-shapes.md
index 34d9a61..40d74ef 100644
--- a/docs/supported-types/arrays-and-shapes.md
+++ b/docs/supported-types/arrays-and-shapes.md
@@ -378,7 +378,50 @@ $service->configure(['driver' => 'pdo_pgsql'], 'id');
$service->configure(['driver' => 'pdo_mysql'], 'invalid');
// Throws: TypeError: Argument $shapeKey must be a key of the specified array shape
```
+---
+## Offset Access Types (`T[K]`)
+
+TypePHP supports evaluating offset access lookups on array shapes, constant arrays, and `@phpstan-type` aliases at runtime using `T[K]` syntax.
+
+> **AST Reduction:** TypePHP evaluates and reduces offset access lookups (e.g. `UserShape['id']` $\rightarrow$ `positive-int`) at the AST level before validation runs, executing type checks at **$O(1)$ constant speed**.
+
+```php
+namespace App\Services;
+
+/**
+ * @phpstan-type UserShape array{id: positive-int, username: non-empty-string}
+ */
+class UserService
+{
+ public const CONFIG_MAP = [
+ 'mysql' => 'PDO\MySQL\Driver',
+ ];
+ /**
+ * Resolves UserShape['id'] directly to positive-int
+ *
+ * @param UserShape['id'] $userId
+ * @param self::CONFIG_MAP['mysql'] $driverClass
+ */
+ public function findUser(int $userId, string $driverClass): void
+ {
+ // ...
+ }
+}
+
+$service = new UserService();
+
+// Valid
+$service->findUser(42, 'PDO\MySQL\Driver');
+
+// Invalid $userId (-5 violates positive-int extracted from UserShape['id'])
+$service->findUser(-5, 'PDO\MySQL\Driver');
+// Throws: TypeError: Argument $userId must be of type positive-int
+
+// Invalid $driverClass ('PDO\PgSQL\Driver' violates literal 'PDO\MySQL\Driver')
+$service->findUser(42, 'PDO\PgSQL\Driver');
+// Throws: TypeError: Argument $driverClass must be literal 'PDO\MySQL\Driver'
+```
---
## Object Shapes (`object{prop: type}` & `stdClass{prop: type}`)
diff --git a/docs/supported-types/primitives-and-scalars.md b/docs/supported-types/primitives-and-scalars.md
index a8ce431..81b66e8 100644
--- a/docs/supported-types/primitives-and-scalars.md
+++ b/docs/supported-types/primitives-and-scalars.md
@@ -112,6 +112,42 @@ setRange(150, 0, 5);
---
+## Integer Bitmasks (`int-mask<...>` & `int-mask-of<...>`)
+
+TypePHP enforces bitwise flag combinations created by bitwise `OR` (`|`) operations on integers using `int-mask` and `int-mask-of`:
+
+| Refinement Keyword | Constraint Rule | Valid Examples | Invalid Examples |
+| :--- | :--- | :--- | :--- |
+| **`int-mask<1, 2, 4>`** | Value must be a valid bitwise combination of the allowed integer flags (or `0`). | `0`, `1`, `3` (`1\|2`), `7` (`1\|2\|4`) | `8`, `10`, `-1` |
+| **`int-mask-of`** | Value must be a valid bitwise combination of class constants matching the wildcard pattern. | `1`, `3`, `7` | `16` |
+
+```php
+class BitmaskFlags
+{
+ public const FLAG_READ = 1; // 0001
+ public const FLAG_WRITE = 2; // 0010
+ public const FLAG_EXECUTE = 4; // 0100
+
+ /**
+ * @param int-mask<1, 2, 4> $mask
+ * @param int-mask-of $wildcardMask
+ */
+ public static function setPermissions(int $mask, int $wildcardMask): void
+ {
+ // ...
+ }
+}
+
+// Valid Call
+BitmaskFlags::setPermissions(3, 7); // 3 = READ | WRITE, 7 = READ | WRITE | EXECUTE
+
+// Invalid (8 contains bits outside allowed mask 1|2|4 = 7)
+BitmaskFlags::setPermissions(8, 7);
+// Throws: TypeError: Argument $mask must be a valid bitmask combination of the allowed flags
+```
+
+---
+
## String Refinements
Validate string lengths, formatting, character casing, and truthiness at runtime:
@@ -122,6 +158,9 @@ Validate string lengths, formatting, character casing, and truthiness at runtime
| **`numeric-string`** | `is_numeric($val) === true` | `'123'`, `'45.67'`, `'-10'` | `'abc'`, `''` |
| **`lowercase-string`** | `strtolower($val) === $val` | `'hello'`, `'user_100'` | `'Hello'`, `'ADMIN'` |
| **`non-empty-lowercase-string`** | Non-empty & lowercase | `'hello'`, `'abc'` | `''`, `'Hello'` |
+| **`uppercase-string`** | `strtoupper($val) === $val` | `'USD'`, `'HELLO_100'` | `'Hello'`, `'admin'` |
+| **`non-empty-uppercase-string`** | Non-empty & uppercase | `'EUR'`, `'ABC'` | `''`, `'Eur'` |
+| **`array-key`** | `is_int($val) \|\| is_string($val)` | `100`, `'user_100'` | `true`, `[]`, `null` |
| **`literal-string`** | String scalar | `'active'`, `'user'` | Non-strings |
| **`truthy-string`**, **`non-falsy-string`** | Evaluates to `true` in boolean context | `'hello'`, `'1'` | `''`, `'0'` |
diff --git a/docs/supported-types/type-aliases.md b/docs/supported-types/type-aliases.md
new file mode 100644
index 0000000..2cfa125
--- /dev/null
+++ b/docs/supported-types/type-aliases.md
@@ -0,0 +1,177 @@
+# Type Aliases
+
+TypePHP supports declaring local type aliases (`@phpstan-type` / `@psalm-type`) and importing type aliases from other classes (`@phpstan-import-type` / `@psalm-import-type`). This allows you to centralize and reuse complex array shapes, unions, and generic structures across your application.
+
+> **Tooling Compatibility:** Both PHPStan syntax (`@phpstan-type`, `@phpstan-import-type`) and Psalm syntax (`@psalm-type`, `@psalm-import-type`) are parsed identically and enforced at runtime.
+
+---
+
+## Local Type Aliases (`@phpstan-type` / `@psalm-type`)
+
+Declare a local type alias above a class or interface definition using `@phpstan-type` or `@psalm-type`. Once declared, you can reference the alias in any parameter, return, or `@var` docblock within that class:
+
+```php
+updateUser(['id' => 10, 'username' => 'Alice', 'role' => 'admin'], 'active');
+
+// Invalid Call ($id is negative, violating UserShape)
+$service->updateUser(['id' => -5, 'username' => 'Alice', 'role' => 'admin'], 'active');
+// Throws: TypeError: UserService::updateUser(): Argument $user['id'] must be of type positive-int
+```
+
+---
+
+## Imported Type Aliases (`@phpstan-import-type` / `@psalm-import-type`)
+
+To share type aliases across multiple classes, declare your aliases in a central class (e.g. `GlobalTypes`) and import them into other classes using `@phpstan-import-type` or `@psalm-import-type`:
+
+### Central Type Definitions (`GlobalTypes.php`)
+
+```php
+namespace App\Types;
+
+/**
+ * Shared Type Definitions
+ *
+ * @phpstan-type SharedUserShape array{id: positive-int, email: non-empty-string}
+ * @psalm-type SharedRole 'admin'|'user'
+ */
+class GlobalTypes
+{
+}
+```
+
+### Importing the Shared Type Alias (`UserApi.php`)
+
+```php
+namespace App\Api;
+
+use App\Types\GlobalTypes;
+
+/**
+ * Import shared types from GlobalTypes
+ *
+ * @phpstan-import-type SharedUserShape from GlobalTypes
+ * @psalm-import-type SharedRole from GlobalTypes
+ */
+class UserApi
+{
+ /**
+ * @param SharedUserShape $user
+ * @param SharedRole $role
+ */
+ public function saveUser(array $user, string $role): bool
+ {
+ return true;
+ }
+}
+
+$api = new UserApi();
+
+// Valid Call
+$api->saveUser(['id' => 42, 'email' => 'alice@example.com'], 'admin');
+
+// Invalid Call ($email is empty string)
+$api->saveUser(['id' => 42, 'email' => ''], 'admin');
+// Throws: TypeError: UserApi::saveUser(): Argument $user['email'] must be of type non-empty-string
+```
+
+---
+
+## Importing with Local Alias Renaming (`as`)
+
+Use the `as` keyword to rename an imported type alias locally to prevent naming collisions or improve local code clarity:
+
+```php
+namespace App\Services;
+
+use App\Types\GlobalTypes;
+
+/**
+ * Import and rename the shared type alias
+ *
+ * @phpstan-import-type SharedUserShape from GlobalTypes as LocalUserShape
+ */
+class AccountService
+{
+ /**
+ * @param LocalUserShape $payload
+ */
+ public function createAccount(array $payload): void
+ {
+ // ...
+ }
+}
+```
+
+---
+
+## Naming Collisions (When `as` is Omitted)
+
+If a class defines a local `@phpstan-type Status` AND imports a type alias with the exact same name (`@phpstan-import-type Status from GlobalTypes`) **without using the `as` keyword**:
+
+1. **Resolution Priority:** The imported type alias will **overwrite** the local type alias.
+2. **Best Practice:** Always use the `as` keyword whenever an imported alias name collides with a local alias name to make your type contracts explicit:
+
+```php
+/**
+ * Local alias: 'active'|'pending'
+ * @phpstan-type Status 'active'|'pending'
+ *
+ * Imported alias renamed to GlobalStatus to prevent overwriting local 'Status'
+ * @phpstan-import-type Status from GlobalTypes as GlobalStatus
+ */
+class OrderService
+{
+ // ...
+}
+```
+
+---
+
+## Chained Type Alias Imports
+
+TypePHP recursively resolves multi-level type alias import chains down to the root definition:
+
+* **Level 1 (`GlobalTypes`):** Defines `@phpstan-type UserShape array{id: positive-int}`.
+* **Level 2 (`MidService`):** Imports `@phpstan-import-type UserShape from GlobalTypes`.
+* **Level 3 (`FinalService`):** Imports `@phpstan-import-type UserShape from MidService as LocalShape`.
+
+When `FinalService` validates `$payload` against `LocalShape`, TypePHP automatically follows the 3-class import chain back to `GlobalTypes` and enforces `array{id: positive-int}`!
+
+```php
+$service = new FinalService();
+
+// Valid Call
+$service->process(['id' => 100]);
+
+// Invalid Call (id is negative)
+$service->process(['id' => -5]);
+// Throws: TypeError: FinalService::process(): Argument $payload['id'] must be of type positive-int
+```
diff --git a/docs/advanced/troubleshooting.md b/docs/troubleshooting.md
similarity index 100%
rename from docs/advanced/troubleshooting.md
rename to docs/troubleshooting.md
diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php
index 2ef8142..b8bde46 100644
--- a/src/Contract/ContractParser.php
+++ b/src/Contract/ContractParser.php
@@ -12,6 +12,7 @@
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
+use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
use TypePHP\Internal\Config;
@@ -263,14 +264,14 @@ private static function parseFunction(\ReflectionFunction $ref): array
if ($isVariadic) {
$type = new ArrayTypeNode($type);
}
- $resolvedType = SpecialTypeResolver::resolve($type, $ref);
- $types[$paramName] = self::substituteAliases($resolvedType, $aliases);
+ $substitutedType = self::substituteAliases($type, $aliases);
+ $types[$paramName] = SpecialTypeResolver::resolve($substitutedType, $ref);
}
$returnTags = $phpDocNode->getReturnTagValues();
if (\count($returnTags) > 0) {
- $resolvedReturn = SpecialTypeResolver::resolve($returnTags[0]->type, $ref);
- $returnType = self::substituteAliases($resolvedReturn, $aliases);
+ $substitutedReturn = self::substituteAliases($returnTags[0]->type, $aliases);
+ $returnType = SpecialTypeResolver::resolve($substitutedReturn, $ref);
}
return [
@@ -331,10 +332,12 @@ private static function parseMethodHierarchyDocs(
$hierarchy = HierarchyResolver::getMethodHierarchy($ref);
$baseParams = $ref->getParameters();
$baseParamNames = [];
+ $baseParamSet = [];
$baseParamVariadic = [];
foreach ($baseParams as $idx => $p) {
$baseParamNames[$idx] = $p->getName();
+ $baseParamSet[$p->getName()] = $idx;
$baseParamVariadic[$p->getName()] = $p->isVariadic();
}
@@ -368,28 +371,32 @@ private static function parseMethodHierarchyDocs(
foreach ($phpDocNode->getParamTagValues() as $paramTag) {
$paramName = ltrim($paramTag->parameterName, '$');
- $paramIndex = $hierNameToIndex[$paramName] ?? null;
- if ($paramIndex !== null && isset($baseParamNames[$paramIndex])) {
- $baseParamName = $baseParamNames[$paramIndex];
+ if (isset($baseParamSet[$paramName])) {
+ $targetParamName = $paramName;
+ } else {
+ $paramIndex = $hierNameToIndex[$paramName] ?? null;
+ $targetParamName = ($paramIndex !== null && isset($baseParamNames[$paramIndex]))
+ ? $baseParamNames[$paramIndex]
+ : null;
+ }
- if (! isset($types[$baseParamName])) {
- $type = $paramTag->type;
- $isVariadic = $paramTag->isVariadic || $baseParamVariadic[$baseParamName];
- if ($isVariadic) {
- $type = new ArrayTypeNode($type);
- }
- $resolvedType = SpecialTypeResolver::resolve($type, $hierRef);
- $types[$baseParamName] = self::substituteAliases($resolvedType, $aliases);
+ if ($targetParamName !== null && ! isset($types[$targetParamName])) {
+ $type = $paramTag->type;
+ $isVariadic = $paramTag->isVariadic || ($baseParamVariadic[$targetParamName] ?? false);
+ if ($isVariadic) {
+ $type = new ArrayTypeNode($type);
}
+ $substitutedType = self::substituteAliases($type, $aliases);
+ $types[$targetParamName] = SpecialTypeResolver::resolve($substitutedType, $hierRef);
}
}
if ($returnType === null) {
$returnTags = $phpDocNode->getReturnTagValues();
if (\count($returnTags) > 0) {
- $resolvedReturn = SpecialTypeResolver::resolve($returnTags[0]->type, $hierRef);
- $returnType = self::substituteAliases($resolvedReturn, $aliases);
+ $substitutedReturn = self::substituteAliases($returnTags[0]->type, $aliases);
+ $returnType = SpecialTypeResolver::resolve($substitutedReturn, $hierRef);
}
}
}
@@ -441,6 +448,13 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN
return $node;
}
+ if ($node instanceof OffsetAccessTypeNode) {
+ return new OffsetAccessTypeNode(
+ self::substituteAliases($node->type, $aliases),
+ self::substituteAliases($node->offset, $aliases)
+ );
+ }
+
if ($node instanceof ArrayTypeNode) {
return new ArrayTypeNode(self::substituteAliases($node->type, $aliases));
}
diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php
index 48bece3..a98636e 100644
--- a/src/Internal/Checker/InlineChecker.php
+++ b/src/Internal/Checker/InlineChecker.php
@@ -80,9 +80,17 @@ public static function checkVariable(mixed $value, string $typeString, string $v
}
if ($className !== null) {
- $classAliases = ContractParser::parseClassAliases($className);
- if (\count($classAliases) > 0) {
- $typeNode = TemplateSubstitutor::substitute($typeNode, $classAliases);
+ if (class_exists($className) || interface_exists($className) || trait_exists($className)) {
+ try {
+ $refClass = new \ReflectionClass($className);
+ $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass);
+
+ $classAliases = ContractParser::parseClassAliases($className);
+ if (\count($classAliases) > 0) {
+ $typeNode = TemplateSubstitutor::substitute($typeNode, $classAliases);
+ }
+ } catch (\ReflectionException $e) {
+ }
}
}
@@ -239,7 +247,29 @@ private static function shouldValidateType(TypeNode $node, array $config): bool
return $checkArrays;
}
- if (\in_array($lower, ['int', 'integer', 'string', 'bool', 'boolean', 'float', 'double', 'null', 'true', 'false', 'scalar', 'numeric', 'positive-int', 'negative-int', 'non-empty-string', 'numeric-string', 'truthy', 'falsy'], true)) {
+ if (\in_array($lower, [
+ 'int',
+ 'integer',
+ 'string',
+ 'bool',
+ 'boolean',
+ 'float',
+ 'double',
+ 'null',
+ 'true',
+ 'false',
+ 'scalar',
+ 'numeric',
+ 'positive-int',
+ 'negative-int',
+ 'non-empty-string',
+ 'numeric-string',
+ 'truthy',
+ 'falsy',
+ 'uppercase-string',
+ 'non-empty-uppercase-string',
+ 'array-key',
+ ], true)) {
return (bool) ($config['scalars'] ?? false);
}
diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php
index e507061..77deb81 100644
--- a/src/Resolver/SpecialTypeResolver.php
+++ b/src/Resolver/SpecialTypeResolver.php
@@ -6,6 +6,8 @@
use PhpParser\Node\Stmt;
use PhpParser\ParserFactory;
+use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode;
+use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode;
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
@@ -20,6 +22,7 @@
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
+use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
@@ -144,6 +147,68 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
);
}
+ if ($node instanceof OffsetAccessTypeNode) {
+ $baseType = self::resolve($node->type, $context, $thisObj);
+ $offsetType = self::resolve($node->offset, $context, $thisObj);
+
+ $offsetKey = null;
+ if ($offsetType instanceof ConstTypeNode) {
+ $expr = $offsetType->constExpr;
+ if ($expr instanceof ConstExprStringNode) {
+ $offsetKey = $expr->value;
+ } elseif ($expr instanceof ConstExprIntegerNode) {
+ $offsetKey = (int) $expr->value;
+ }
+ } elseif ($offsetType instanceof IdentifierTypeNode) {
+ $offsetKey = $offsetType->name;
+ }
+
+ if ($offsetKey !== null) {
+ if ($baseType instanceof ArrayShapeNode) {
+ foreach ($baseType->items as $item) {
+ $itemKey = null;
+ if ($item->keyName instanceof ConstExprStringNode) {
+ $itemKey = $item->keyName->value;
+ } elseif ($item->keyName instanceof IdentifierTypeNode) {
+ $itemKey = $item->keyName->name;
+ } elseif ($item->keyName instanceof ConstExprIntegerNode) {
+ $itemKey = (int) $item->keyName->value;
+ }
+
+ if ((string) $itemKey === (string) $offsetKey) {
+ return $item->valueType;
+ }
+ }
+ }
+
+ if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) {
+ $constExpr = $baseType->constExpr;
+ $fqcn = $constExpr->className;
+ $constName = $constExpr->name;
+
+ if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) {
+ try {
+ $refClass = new \ReflectionClass($fqcn);
+ if ($refClass->hasConstant($constName)) {
+ $constValue = $refClass->getConstant($constName);
+ if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) {
+ $val = $constValue[$offsetKey];
+ if (\is_string($val)) {
+ return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED));
+ } elseif (\is_int($val)) {
+ return new ConstTypeNode(new ConstExprIntegerNode((string) $val));
+ }
+ }
+ }
+ } catch (\ReflectionException $e) {
+ }
+ }
+ }
+ }
+
+ return new OffsetAccessTypeNode($baseType, $offsetType);
+ }
+
if ($node instanceof ArrayShapeNode) {
$items = array_map(function ($item) use ($context, $thisObj) {
return new ArrayShapeItemNode(
@@ -277,6 +342,68 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode
);
}
+ if ($node instanceof OffsetAccessTypeNode) {
+ $baseType = self::resolveForFile($node->type, $file);
+ $offsetType = self::resolveForFile($node->offset, $file);
+
+ $offsetKey = null;
+ if ($offsetType instanceof ConstTypeNode) {
+ $expr = $offsetType->constExpr;
+ if ($expr instanceof ConstExprStringNode) {
+ $offsetKey = $expr->value;
+ } elseif ($expr instanceof ConstExprIntegerNode) {
+ $offsetKey = (int) $expr->value;
+ }
+ } elseif ($offsetType instanceof IdentifierTypeNode) {
+ $offsetKey = $offsetType->name;
+ }
+
+ if ($offsetKey !== null) {
+ if ($baseType instanceof ArrayShapeNode) {
+ foreach ($baseType->items as $item) {
+ $itemKey = null;
+ if ($item->keyName instanceof ConstExprStringNode) {
+ $itemKey = $item->keyName->value;
+ } elseif ($item->keyName instanceof IdentifierTypeNode) {
+ $itemKey = $item->keyName->name;
+ } elseif ($item->keyName instanceof ConstExprIntegerNode) {
+ $itemKey = (int) $item->keyName->value;
+ }
+
+ if ((string) $itemKey === (string) $offsetKey) {
+ return $item->valueType;
+ }
+ }
+ }
+
+ if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) {
+ $constExpr = $baseType->constExpr;
+ $fqcn = $constExpr->className;
+ $constName = $constExpr->name;
+
+ if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) {
+ try {
+ $refClass = new \ReflectionClass($fqcn);
+ if ($refClass->hasConstant($constName)) {
+ $constValue = $refClass->getConstant($constName);
+ if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) {
+ $val = $constValue[$offsetKey];
+ if (\is_string($val)) {
+ return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED));
+ } elseif (\is_int($val)) {
+ return new ConstTypeNode(new ConstExprIntegerNode((string) $val));
+ }
+ }
+ }
+ } catch (\ReflectionException $e) {
+ }
+ }
+ }
+ }
+
+ return new OffsetAccessTypeNode($baseType, $offsetType);
+ }
+
if ($node instanceof ArrayShapeNode) {
$items = array_map(function ($item) use ($file) {
return new ArrayShapeItemNode(
@@ -523,14 +650,70 @@ public static function resolveFqcnForFile(string $name, string $file): string
private static function isBuiltInTypeKeyword(string $name): bool
{
return \in_array(strtolower($name), [
- 'int', 'integer', 'string', 'float', 'double', 'bool', 'boolean', 'array', 'list', 'object', 'callable',
- 'iterable', 'resource', 'null', 'true', 'false', 'mixed', 'scalar', 'void', 'self', 'static', 'parent', '$this',
- 'positive-int', 'negative-int', 'non-positive-int', 'non-negative-int', 'non-zero-int', 'unsigned-int',
- 'positive-float', 'negative-float', 'non-positive-float', 'non-negative-float', 'non-zero-float',
- 'class-string', 'interface-string', 'trait-string', 'enum-string', 'callable-string', 'numeric-string',
- 'non-empty-string', 'lowercase-string', 'non-empty-lowercase-string', 'literal-string', 'truthy-string',
- 'non-empty-array', 'non-empty-list', 'number', 'numeric', 'truthy', 'falsy', 'falsey', 'min', 'max', '*',
- 'never', 'never-return', 'never-returns', 'no-return', 'open-resource', 'closed-resource',
+ 'int',
+ 'integer',
+ 'string',
+ 'float',
+ 'double',
+ 'bool',
+ 'boolean',
+ 'array',
+ 'list',
+ 'object',
+ 'callable',
+ 'iterable',
+ 'resource',
+ 'null',
+ 'true',
+ 'false',
+ 'mixed',
+ 'scalar',
+ 'void',
+ 'self',
+ 'static',
+ 'parent',
+ '$this',
+ 'positive-int',
+ 'negative-int',
+ 'non-positive-int',
+ 'non-negative-int',
+ 'non-zero-int',
+ 'unsigned-int',
+ 'positive-float',
+ 'negative-float',
+ 'non-positive-float',
+ 'non-negative-float',
+ 'non-zero-float',
+ 'class-string',
+ 'interface-string',
+ 'trait-string',
+ 'enum-string',
+ 'callable-string',
+ 'numeric-string',
+ 'non-empty-string',
+ 'lowercase-string',
+ 'non-empty-lowercase-string',
+ 'uppercase-string',
+ 'non-empty-uppercase-string',
+ 'array-key',
+ 'literal-string',
+ 'truthy-string',
+ 'non-empty-array',
+ 'non-empty-list',
+ 'number',
+ 'numeric',
+ 'truthy',
+ 'falsy',
+ 'falsey',
+ 'min',
+ 'max',
+ '*',
+ 'never',
+ 'never-return',
+ 'never-returns',
+ 'no-return',
+ 'open-resource',
+ 'closed-resource',
], true);
}
diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php
index 5ef417f..d822ff5 100644
--- a/src/Validator/GenericValidator.php
+++ b/src/Validator/GenericValidator.php
@@ -19,7 +19,7 @@
use TypePHP\Internal\TypeFormatter;
/**
- * @internal values against generic AST structures (int ranges, class-string, list, array, object generics).
+ * @internal Validates values against generic AST structures (int ranges, class-string, list, array, object generics, key-of, value-of, int-mask, int-mask-of).
*/
final class GenericValidator implements TypeValidatorInterface
{
@@ -38,6 +38,9 @@ final class GenericValidator implements TypeValidatorInterface
*/
private static array $enumValueCache = [];
+ /**
+ * Validates a value against a GenericTypeNode AST.
+ */
public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage
{
/** @var GenericTypeNode $genericNode */
@@ -51,10 +54,43 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
'array', 'non-empty-array', 'iterable', 'traversable', 'generator', 'iterator' => $this->validateArray($value, $genericNode, $context, $registry),
'key-of' => $this->validateKeyOf($value, $genericNode, $context),
'value-of' => $this->validateValueOf($value, $genericNode, $context),
+ 'int-mask' => $this->validateIntMask($value, $genericNode, $context),
+ 'int-mask-of' => $this->validateIntMaskOf($value, $genericNode, $context),
default => $this->validateObjectGeneric($value, $genericNode, $context),
};
}
+ /**
+ * Helper to resolve and cache class or global constant values in static memory.
+ */
+ private function resolveConstantValue(string $fqcn, string $constName): mixed
+ {
+ $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName;
+
+ if (! \array_key_exists($cacheKey, self::$constantCache)) {
+ $constValue = false;
+ if ($fqcn !== '') {
+ if (class_exists($fqcn) || interface_exists($fqcn)) {
+ try {
+ $refClass = new \ReflectionClass($fqcn);
+ if ($refClass->hasConstant($constName)) {
+ $constValue = $refClass->getConstant($constName);
+ }
+ } catch (\ReflectionException $e) {
+ // Silently ignore reflection errors
+ }
+ }
+ } else {
+ if (\defined($constName)) {
+ $constValue = \constant($constName);
+ }
+ }
+ self::$constantCache[$cacheKey] = $constValue;
+ }
+
+ return self::$constantCache[$cacheKey];
+ }
+
/**
* Validates key-of generic structures with O(1) in-memory caching.
*
@@ -78,27 +114,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont
$constName = $constExpr->name;
$cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName;
- if (! \array_key_exists($cacheKey, self::$constantCache)) {
- $constValue = false;
- if ($fqcn !== '') {
- if (class_exists($fqcn) || interface_exists($fqcn)) {
- try {
- $refClass = new \ReflectionClass($fqcn);
- if ($refClass->hasConstant($constName)) {
- $constValue = $refClass->getConstant($constName);
- }
- } catch (\ReflectionException $e) {
- }
- }
- } else {
- if (\defined($constName)) {
- $constValue = \constant($constName);
- }
- }
- self::$constantCache[$cacheKey] = $constValue;
- }
-
- $constValue = self::$constantCache[$cacheKey];
+ $constValue = $this->resolveConstantValue($fqcn, $constName);
if (\is_array($constValue)) {
if ((! \is_int($value) && ! \is_string($value)) || ! \array_key_exists($value, $constValue)) {
@@ -163,27 +179,7 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co
$constName = $constExpr->name;
$cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName;
- if (! \array_key_exists($cacheKey, self::$constantCache)) {
- $constValue = false;
- if ($fqcn !== '') {
- if (class_exists($fqcn) || interface_exists($fqcn)) {
- try {
- $refClass = new \ReflectionClass($fqcn);
- if ($refClass->hasConstant($constName)) {
- $constValue = $refClass->getConstant($constName);
- }
- } catch (\ReflectionException $e) {
- }
- }
- } else {
- if (\defined($constName)) {
- $constValue = \constant($constName);
- }
- }
- self::$constantCache[$cacheKey] = $constValue;
- }
-
- $constValue = self::$constantCache[$cacheKey];
+ $constValue = $this->resolveConstantValue($fqcn, $constName);
if (\is_array($constValue)) {
if (! \in_array($value, $constValue, true)) {
@@ -210,6 +206,95 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co
return null;
}
+ /**
+ * Validates int-mask<1, 2, 4> bitmask flags combinations.
+ */
+ private function validateIntMask(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage
+ {
+ if (! \is_int($value)) {
+ return ErrorFactory::createError($context . ' must be of type int (bitmask), ' . TypeFormatter::formatGivenValue($value) . ' given');
+ }
+
+ $allowedMask = 0;
+
+ foreach ($node->genericTypes as $typeNode) {
+ if ($typeNode instanceof ConstTypeNode) {
+ $expr = $typeNode->constExpr;
+ if ($expr instanceof ConstExprIntegerNode) {
+ $allowedMask |= (int) $expr->value;
+ } elseif ($expr instanceof ConstFetchNode) {
+ $constVal = $this->resolveConstantValue($expr->className, $expr->name);
+ if (\is_int($constVal)) {
+ $allowedMask |= $constVal;
+ }
+ }
+ }
+ }
+
+ if (($value & ~$allowedMask) !== 0) {
+ return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value) . ' given');
+ }
+
+ return null;
+ }
+
+ /**
+ * Validates int-mask-of bitmask flags combinations from constant patterns.
+ */
+ private function validateIntMaskOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage
+ {
+ if (! \is_int($value)) {
+ return ErrorFactory::createError($context . ' must be of type int (bitmask), ' . TypeFormatter::formatGivenValue($value) . ' given');
+ }
+
+ $targetType = $node->genericTypes[0] ?? null;
+ $allowedMask = 0;
+ $foundFlags = false;
+
+ if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstFetchNode) {
+ $constExpr = $targetType->constExpr;
+ $fqcn = $constExpr->className;
+ $pattern = $constExpr->name;
+
+ if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) {
+ try {
+ $refClass = new \ReflectionClass($fqcn);
+
+ if (str_contains($pattern, '*')) {
+ $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i';
+ foreach ($refClass->getConstants() as $cName => $cValue) {
+ if (\is_int($cValue) && preg_match($regex, $cName) === 1) {
+ $allowedMask |= $cValue;
+ $foundFlags = true;
+ }
+ }
+ } else {
+ $cValue = $this->resolveConstantValue($fqcn, $pattern);
+ if (\is_int($cValue)) {
+ $allowedMask |= $cValue;
+ $foundFlags = true;
+ } elseif (\is_array($cValue)) {
+ foreach ($cValue as $item) {
+ if (\is_int($item)) {
+ $allowedMask |= $item;
+ $foundFlags = true;
+ }
+ }
+ }
+ }
+ } catch (\ReflectionException $e) {
+ // Silently ignore reflection errors
+ }
+ }
+ }
+
+ if ($foundFlags && ($value & ~$allowedMask) !== 0) {
+ return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value) . ' given');
+ }
+
+ return null;
+ }
+
/**
* Validates integer ranges (e.g. int<1, 100> or int).
*/
@@ -365,9 +450,14 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont
/**
* Validates object generic instances and binds template parameters.
+ * Gracefully ignores generic annotations with invalid class syntax (e.g. custom-generic).
*/
private function validateObjectGeneric(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage
{
+ if (! ClassNameValidator::isValid($node->type->name)) {
+ return null;
+ }
+
if (! \is_object($value)) {
return ErrorFactory::createError($context . ' must be an object of type ' . $node->type->name . ', ' . TypeFormatter::formatGivenValue($value) . ' given');
}
diff --git a/src/Validator/IdentifierValidator.php b/src/Validator/IdentifierValidator.php
index c820172..4b47815 100644
--- a/src/Validator/IdentifierValidator.php
+++ b/src/Validator/IdentifierValidator.php
@@ -62,6 +62,9 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
'non-empty-string' => \is_string($value) && $value !== '',
'lowercase-string' => \is_string($value) && strtolower($value) === $value,
'non-empty-lowercase-string' => \is_string($value) && $value !== '' && strtolower($value) === $value,
+ 'uppercase-string' => \is_string($value) && strtoupper($value) === $value,
+ 'non-empty-uppercase-string' => \is_string($value) && $value !== '' && strtoupper($value) === $value,
+ 'array-key' => \is_int($value) || \is_string($value),
'literal-string' => \is_string($value),
'truthy-string', 'non-falsy-string' => \is_string($value) && (bool) $value === true,
'non-empty-array' => \is_array($value) && \count($value) > 0,
diff --git a/tests/Fixtures/Attributes/BaseField.php b/tests/Fixtures/Attributes/BaseField.php
new file mode 100644
index 0000000..d919a39
--- /dev/null
+++ b/tests/Fixtures/Attributes/BaseField.php
@@ -0,0 +1,22 @@
+|null
+ */
+ #[OneToManyRelation(
+ entity: 'measurement_display_unit',
+ ref: 'measurement_system_id',
+ onDelete: OnDeleteOption::CASCADE,
+ api: true
+ )]
+ public ?array $units = null;
+}
diff --git a/tests/Fixtures/Attributes/OnDeleteOption.php b/tests/Fixtures/Attributes/OnDeleteOption.php
new file mode 100644
index 0000000..c73a6e0
--- /dev/null
+++ b/tests/Fixtures/Attributes/OnDeleteOption.php
@@ -0,0 +1,11 @@
+ $userId, $name -> $userName, $role -> $userRole
+ * and adds an optional parameter $notify
+ *
+ * @param bool $notify
+ */
+ public function registerUser(int $userId, string $userName, string $userRole = 'user', bool $notify = false): bool
+ {
+ return parent::registerUser($userId, $userName, $userRole);
+ }
+}
diff --git a/tests/Fixtures/Types/BitmaskFlags.php b/tests/Fixtures/Types/BitmaskFlags.php
new file mode 100644
index 0000000..a89b0d7
--- /dev/null
+++ b/tests/Fixtures/Types/BitmaskFlags.php
@@ -0,0 +1,28 @@
+ $mask
+ */
+ public static function checkLiteralMask(int $mask): int
+ {
+ return $mask;
+ }
+
+ /**
+ * @param int-mask-of $mask
+ */
+ public static function checkWildcardMask(int $mask): int
+ {
+ return $mask;
+ }
+}
diff --git a/tests/Fixtures/Types/CurrencyFormatter.php b/tests/Fixtures/Types/CurrencyFormatter.php
new file mode 100644
index 0000000..30833b7
--- /dev/null
+++ b/tests/Fixtures/Types/CurrencyFormatter.php
@@ -0,0 +1,27 @@
+ 'PDO\MySQL\Driver',
+ ];
+
+ /**
+ * @param UserShape['id'] $id
+ */
+ public function setUserId(int $id): int
+ {
+ return $id;
+ }
+
+ /**
+ * @param self::CONFIG_MAP['mysql'] $driverClass
+ */
+ public static function setDriver(string $driverClass): string
+ {
+ return $driverClass;
+ }
+}
diff --git a/tests/TypeChecking/AttributeConstructorInheritanceTest.php b/tests/TypeChecking/AttributeConstructorInheritanceTest.php
new file mode 100644
index 0000000..ee295c1
--- /dev/null
+++ b/tests/TypeChecking/AttributeConstructorInheritanceTest.php
@@ -0,0 +1,64 @@
+toBeInstanceOf(OneToManyRelation::class);
+ });
+
+ test('reproduces parameter index mismatch bug when instantiated via PHP 8 ReflectionAttribute::newInstance()', function () {
+ $refProp = new ReflectionProperty(MeasurementSystemEntity::class, 'units');
+ $refAttr = $refProp->getAttributes(OneToManyRelation::class)[0];
+
+ $attrInstance = $refAttr->newInstance();
+
+ expect($attrInstance)->toBeInstanceOf(OneToManyRelation::class);
+ });
+
+ });
+
+ describe('Multi-Level 3-Tier Parameter Shift Edge Cases', function () {
+
+ test('correctly maps inherited contracts across 3 hierarchy levels with multiple position shifts', function () {
+ $field = new DeepMultiLevelField(10, 'entity', 'unit_name', true);
+
+ expect($field)->toBeInstanceOf(DeepMultiLevelField::class);
+ });
+
+ test('throws TypeError when $id violates local child contract in 3-tier hierarchy', function () {
+ expect(fn () => new DeepMultiLevelField(-5, 'entity', 'unit_name', true))
+ ->toThrow(TypeError::class, 'Argument $id must be of type positive-int')
+ ;
+ });
+
+ test('throws TypeError when $type violates 3rd-tier grand-parent contract', function () {
+ expect(fn () => new DeepMultiLevelField(10, '', 'unit_name', true))
+ ->toThrow(TypeError::class, 'Argument $type must be of type non-empty-string')
+ ;
+ });
+
+ test('throws TypeError when $name violates 2nd-tier parent contract', function () {
+ expect(fn () => new DeepMultiLevelField(10, 'entity', '', true))
+ ->toThrow(TypeError::class, 'Argument $name must be of type non-empty-string')
+ ;
+ });
+
+ });
+
+});
diff --git a/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php b/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php
index 0cb2978..b45e245 100644
--- a/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php
+++ b/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php
@@ -12,6 +12,16 @@ function testUnsupportedTypeSyntax(string $specialType): string
return $specialType;
}
+/**
+ * Custom generic annotation with invalid class syntax (contains hyphens)
+ *
+ * @param custom-generic $customGeneric
+ */
+function testUnsupportedGenericSyntax(string $customGeneric): string
+{
+ return $customGeneric;
+}
+
/**
* Valid PHP class name syntax for a class that does not exist at runtime
*
@@ -23,9 +33,8 @@ function testNonExistentClassType(mixed $param): mixed
}
test('ignores custom unsupported type syntax with hyphens gracefully', function () {
- $result = testUnsupportedTypeSyntax('special-type');
-
- expect($result)->toBe('special-type');
+ expect(testUnsupportedTypeSyntax('special-type'))->toBe('special-type');
+ expect(testUnsupportedGenericSyntax('hello'))->toBe('hello');
});
test('strictly validates valid class syntax even if class does not exist at runtime', function () {
diff --git a/tests/TypeChecking/InlineVariableValidationTest.php b/tests/TypeChecking/InlineVariableValidationTest.php
index b85041c..dba6320 100644
--- a/tests/TypeChecking/InlineVariableValidationTest.php
+++ b/tests/TypeChecking/InlineVariableValidationTest.php
@@ -233,3 +233,50 @@ function fetchBroadTuple(int $id, string $name): array
expect($token)->toBe('valid_token');
});
});
+
+describe('Inline @var Validation for New Features', function () {
+
+ test('enforces array-key on inline local variables', function () {
+ /** @var array-key $key */
+ $key = 'user_123';
+ expect($key)->toBe('user_123');
+
+ $key = 456;
+ expect($key)->toBe(456);
+
+ expect(fn () => $key = false)
+ ->toThrow(TypeError::class, 'Variable $key must be of type array-key')
+ ;
+ });
+
+ test('enforces uppercase-string on inline local variables', function () {
+ /** @var non-empty-uppercase-string $code */
+ $code = 'USD';
+ expect($code)->toBe('USD');
+
+ expect(fn () => $code = 'usd')
+ ->toThrow(TypeError::class, 'Variable $code must be of type non-empty-uppercase-string')
+ ;
+ });
+
+ test('enforces key-of on inline local variables', function () {
+ /** @var key-of $driver */
+ $driver = 'pdo_mysql';
+ expect($driver)->toBe('pdo_mysql');
+
+ expect(fn () => $driver = 'pdo_invalid')
+ ->toThrow(TypeError::class, 'Variable $driver')
+ ;
+ });
+
+ test('enforces int-mask on inline local variables', function () {
+ /** @var int-mask<1, 2, 4> $mask */
+ $mask = 3; // 1 | 2
+ expect($mask)->toBe(3);
+
+ expect(fn () => $mask = 8)
+ ->toThrow(TypeError::class, 'Variable $mask')
+ ;
+ });
+
+});
diff --git a/tests/TypeChecking/IntMaskTest.php b/tests/TypeChecking/IntMaskTest.php
new file mode 100644
index 0000000..4d12888
--- /dev/null
+++ b/tests/TypeChecking/IntMaskTest.php
@@ -0,0 +1,78 @@
+ $mask
+ */
+function testLiteralIntMask(int $mask): int
+{
+ return $mask;
+}
+
+describe('int-mask and int-mask-of Annotations', function () {
+
+ describe('int-mask<1, 2, 4>', function () {
+
+ test('accepts valid bitmask flag combinations and zero', function () {
+ expect(testLiteralIntMask(0))->toBe(0); // No flags
+ expect(testLiteralIntMask(1))->toBe(1); // READ
+ expect(testLiteralIntMask(3))->toBe(3); // READ | WRITE
+ expect(testLiteralIntMask(7))->toBe(7); // READ | WRITE | EXECUTE
+ expect(BitmaskFlags::checkLiteralMask(5))->toBe(5); // READ | EXECUTE
+ });
+
+ test('throws TypeError when bitmask contains illegal bits', function () {
+ expect(fn () => testLiteralIntMask(8)) // 8 (1000) is outside allowed mask 7 (0111)
+ ->toThrow(TypeError::class, 'must be a valid bitmask combination')
+ ;
+
+ expect(fn () => BitmaskFlags::checkLiteralMask(10))
+ ->toThrow(TypeError::class, 'must be a valid bitmask combination')
+ ;
+ });
+ });
+
+ describe('int-mask-of', function () {
+
+ test('accepts valid bitmask combinations matching wildcard constants', function () {
+ expect(BitmaskFlags::checkWildcardMask(1))->toBe(1);
+ expect(BitmaskFlags::checkWildcardMask(3))->toBe(3);
+ expect(BitmaskFlags::checkWildcardMask(7))->toBe(7);
+ });
+
+ test('throws TypeError on invalid bitmask for wildcard constants', function () {
+ expect(fn () => BitmaskFlags::checkWildcardMask(16))
+ ->toThrow(TypeError::class, 'must be a valid bitmask combination')
+ ;
+ });
+ });
+
+ describe('Inline @var Constant Bitmasks', function () {
+
+ test('enforces int-mask with specific class constants on inline variables', function () {
+ /** @var int-mask $mask */
+ $mask = 1;
+ expect($mask)->toBe(1);
+
+ $mask = 3;
+ expect($mask)->toBe(3);
+
+ expect(fn () => $mask = 4)
+ ->toThrow(TypeError::class, 'Variable $mask must be a valid bitmask combination')
+ ;
+ });
+
+ test('enforces int-mask-of with wildcard constant patterns on inline variables', function () {
+ /** @var int-mask-of $wildcardMask */
+ $wildcardMask = 7;
+ expect($wildcardMask)->toBe(7);
+
+ expect(fn () => $wildcardMask = 16)
+ ->toThrow(TypeError::class, 'Variable $wildcardMask must be a valid bitmask combination')
+ ;
+ });
+ });
+});
diff --git a/tests/TypeChecking/KeyOfValueOfTest.php b/tests/TypeChecking/KeyOfValueOfTest.php
index 18a0502..42eb786 100644
--- a/tests/TypeChecking/KeyOfValueOfTest.php
+++ b/tests/TypeChecking/KeyOfValueOfTest.php
@@ -153,7 +153,8 @@ function testEnumValueOf(string $statusValue): string
expect($conn->localAction(['action' => 'start']))->toBeTrue();
expect(fn () => $conn->localAction(['action' => 'pause']))
- ->toThrow(TypeError::class, "['action'] must be a key of TypePHP\Tests\Fixtures\Types\DoctrineLikeConnection::LOCAL_ACTIONS");
+ ->toThrow(TypeError::class, "['action'] must be a key of TypePHP\Tests\Fixtures\Types\DoctrineLikeConnection::LOCAL_ACTIONS")
+ ;
});
});
diff --git a/tests/TypeChecking/NamedArgumentsTest.php b/tests/TypeChecking/NamedArgumentsTest.php
new file mode 100644
index 0000000..a44a3d3
--- /dev/null
+++ b/tests/TypeChecking/NamedArgumentsTest.php
@@ -0,0 +1,63 @@
+ $age
+ */
+function testNamedArgsFunction(int $id, string $username, int $age): bool
+{
+ return true;
+}
+
+describe('PHP 8.0+ Named Arguments and Renamed Parameter Positions', function () {
+
+ describe('Standalone Function with Swapped Named Arguments', function () {
+ test('accepts valid named arguments passed in completely reversed/swapped order', function () {
+ expect(testNamedArgsFunction(age: 25, username: 'Alice', id: 42))->toBeTrue();
+ });
+
+ test('throws TypeError on invalid named argument regardless of passed argument position', function () {
+ expect(fn () => testNamedArgsFunction(age: 25, username: 'Alice', id: -5))
+ ->toThrow(TypeError::class, 'Argument $id must be of type positive-int')
+ ;
+
+ expect(fn () => testNamedArgsFunction(username: '', id: 42, age: 25))
+ ->toThrow(TypeError::class, 'Argument $username must be of type non-empty-string')
+ ;
+
+ expect(fn () => testNamedArgsFunction(id: 42, age: 150, username: 'Alice'))
+ ->toThrow(TypeError::class, 'Argument $age')
+ ;
+ });
+
+ });
+
+ describe('Class Method with Renamed Parameters in Method Inheritance', function () {
+
+ test('correctly maps inherited contracts when child renames parameters and is called with named arguments in random order', function () {
+ $service = new ShiftedParamService();
+ expect($service->registerUser(notify: true, userRole: 'admin', userId: 100, userName: 'Bob'))->toBeTrue();
+ });
+
+ test('throws TypeError on invalid parameter when child renames parameters in method inheritance', function () {
+ $service = new ShiftedParamService();
+
+ expect(fn () => $service->registerUser(userRole: 'admin', userName: 'Bob', userId: -10))
+ ->toThrow(TypeError::class, 'Argument $userId must be of type positive-int')
+ ;
+
+ expect(fn () => $service->registerUser(userRole: 'superadmin', userName: 'Bob', userId: 100))
+ ->toThrow(TypeError::class, 'Argument $userRole')
+ ;
+ });
+
+ });
+
+});
diff --git a/tests/TypeChecking/OffsetAccessTest.php b/tests/TypeChecking/OffsetAccessTest.php
new file mode 100644
index 0000000..75dab54
--- /dev/null
+++ b/tests/TypeChecking/OffsetAccessTest.php
@@ -0,0 +1,44 @@
+setUserId(42))->toBe(42);
+
+ expect(fn () => $container->setUserId(-5))
+ ->toThrow(TypeError::class, 'must be of type positive-int')
+ ;
+ });
+
+ test('resolves constant offset from self::CONFIG_MAP[\'mysql\'] to literal string', function () {
+ expect(OffsetAccessContainer::setDriver('PDO\MySQL\Driver'))->toBe('PDO\MySQL\Driver');
+
+ expect(fn () => OffsetAccessContainer::setDriver('PDO\PgSQL\Driver'))
+ ->toThrow(TypeError::class, 'must be literal')
+ ;
+ });
+
+ test('resolves direct inline shape offset array{...}[\'status\'] to union status', function () {
+ expect(testDirectShapeOffset('active'))->toBe('active');
+ expect(testDirectShapeOffset('pending'))->toBe('pending');
+
+ expect(fn () => testDirectShapeOffset('archived'))
+ ->toThrow(TypeError::class, "('active' | 'pending')")
+ ;
+ });
+
+});
diff --git a/tests/TypeChecking/UppercaseAndArrayKeyTest.php b/tests/TypeChecking/UppercaseAndArrayKeyTest.php
new file mode 100644
index 0000000..e585925
--- /dev/null
+++ b/tests/TypeChecking/UppercaseAndArrayKeyTest.php
@@ -0,0 +1,104 @@
+toBe(100);
+ expect(testArrayKeyParam('user_100'))->toBe('user_100');
+ });
+
+ test('throws TypeError when array-key is a boolean or array', function () {
+ expect(fn () => testArrayKeyParam(true))
+ ->toThrow(TypeError::class, 'must be of type array-key')
+ ;
+
+ expect(fn () => testArrayKeyParam([]))
+ ->toThrow(TypeError::class, 'must be of type array-key')
+ ;
+ });
+
+ });
+
+ describe('uppercase-string & non-empty-uppercase-string', function () {
+
+ test('accepts valid uppercase strings', function () {
+ expect(testUppercaseStringParam('USD'))->toBe('USD');
+ expect(testUppercaseStringParam(''))->toBe('');
+ expect(testNonEmptyUppercaseStringParam('EUR'))->toBe('EUR');
+ });
+
+ test('throws TypeError on lowercase or mixed-case string for uppercase-string', function () {
+ expect(fn () => testUppercaseStringParam('Usd'))
+ ->toThrow(TypeError::class, 'must be of type uppercase-string')
+ ;
+
+ expect(fn () => testUppercaseStringParam('usd'))
+ ->toThrow(TypeError::class, 'must be of type uppercase-string')
+ ;
+ });
+
+ test('throws TypeError on empty string for non-empty-uppercase-string', function () {
+ expect(fn () => testNonEmptyUppercaseStringParam(''))
+ ->toThrow(TypeError::class, 'must be of type non-empty-uppercase-string')
+ ;
+ });
+
+ });
+
+ describe('Fixture Class Method Verification (CurrencyFormatter)', function () {
+
+ test('validates parameters on CurrencyFormatter class methods', function () {
+ $formatter = new CurrencyFormatter();
+
+ expect($formatter->formatAccount('USD', 101))->toBe('USD_101');
+ expect($formatter->formatAccount('GBP', 'acc_202'))->toBe('GBP_acc_202');
+
+ expect(fn () => $formatter->formatAccount('usd', 101))
+ ->toThrow(TypeError::class, 'must be of type non-empty-uppercase-string')
+ ;
+
+ expect(fn () => $formatter->formatAccount('USD', true))
+ ->toThrow(TypeError::class, 'must be of type array-key')
+ ;
+ });
+
+ test('validates static method returns on CurrencyFormatter', function () {
+ expect(CurrencyFormatter::sanitizeCode('CAD'))->toBe('CAD');
+
+ expect(fn () => CurrencyFormatter::sanitizeCode('cad'))
+ ->toThrow(TypeError::class, 'Return value');
+ });
+
+ });
+
+});
diff --git a/tests/Unit/ValidatorsTest.php b/tests/Unit/ValidatorsTest.php
index 4cd621a..5062bf5 100644
--- a/tests/Unit/ValidatorsTest.php
+++ b/tests/Unit/ValidatorsTest.php
@@ -124,6 +124,26 @@ function parseType(string $typeString, Lexer $lexer, TypeParser $typeParser): Ty
$neverNode = parseType('never', $this->lexer, $this->typeParser);
expect($this->registry->validate('returned_value', $neverNode, 'arg'))->toBeInstanceOf(ErrorMessage::class);
});
+
+ test('validates array-key pseudo-type (int|string)', function () {
+ $arrayKeyNode = parseType('array-key', $this->lexer, $this->typeParser);
+
+ expect($this->registry->validate(123, $arrayKeyNode, 'arg'))->toBeNull();
+ expect($this->registry->validate('custom_key', $arrayKeyNode, 'arg'))->toBeNull();
+ expect($this->registry->validate(true, $arrayKeyNode, 'arg'))->toBeInstanceOf(ErrorMessage::class);
+ expect($this->registry->validate([], $arrayKeyNode, 'arg'))->toBeInstanceOf(ErrorMessage::class);
+ });
+
+ test('validates uppercase-string and non-empty-uppercase-string', function () {
+ $uppercase = parseType('uppercase-string', $this->lexer, $this->typeParser);
+ expect($this->registry->validate('USD', $uppercase, 'arg'))->toBeNull();
+ expect($this->registry->validate('hello', $uppercase, 'arg'))->toBeInstanceOf(ErrorMessage::class);
+
+ $nonEmptyUppercase = parseType('non-empty-uppercase-string', $this->lexer, $this->typeParser);
+ expect($this->registry->validate('EUR', $nonEmptyUppercase, 'arg'))->toBeNull();
+ expect($this->registry->validate('', $nonEmptyUppercase, 'arg'))->toBeInstanceOf(ErrorMessage::class);
+ expect($this->registry->validate('eur', $nonEmptyUppercase, 'arg'))->toBeInstanceOf(ErrorMessage::class);
+ });
});
describe('ConstValidator', function () {