Skip to content

Commit bddfe72

Browse files
authored
Merge pull request #26 from typephp-php/internal-improvements-5
Comprehensive Bug Fixes, Late Static Binding, Type Alias Expansion & Line-Drift Hardening
1 parent 032a2e9 commit bddfe72

36 files changed

Lines changed: 1479 additions & 444 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
docs/.vitepress/cache
66
docs/.vitepress/dist
77
/manual-tests
8-
composer.lock
8+
composer.lock
9+
index.php

docs/advanced/liskov-and-inheritance.md

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,9 @@ $model->setTraitId(-50);
205205
AppModel::setTraitVersion('');
206206
// Throws: TypeError: Property AppModel::$traitVersion must be of type non-empty-string
207207
```
208+
208209
---
210+
209211
## Trait Inheritance Across Parent-Child Classes
210212

211213
When a parent class uses a Trait (`ParentClass` uses `LoggerTrait`), any child class extending the parent (`ChildClass extends ParentClass`) automatically inherits all `@param`, `@return`, and `@var` contracts declared on the parent's Trait:
@@ -242,6 +244,43 @@ $child->logMessage(10, 'boot');
242244
$child->logMessage(-50, 'boot');
243245
// Throws: TypeError: ChildService::logMessage(): Argument $level must be of type positive-int
244246
```
247+
248+
---
249+
250+
## Trait Method Aliasing (`use Trait { oldMethod as newMethod; }`)
251+
252+
When a class uses a Trait and renames a method using PHP's trait `as` alias syntax, TypePHP inspects trait alias mappings and automatically inherits the original Trait method's DocBlock contracts onto the aliased method:
253+
254+
```php
255+
trait LoggerTrait
256+
{
257+
/**
258+
* @param positive-int $level
259+
* @param non-empty-string $message
260+
*/
261+
public function logEvent(int $level, string $message): bool
262+
{
263+
return true;
264+
}
265+
}
266+
267+
class AuditService
268+
{
269+
use LoggerTrait {
270+
logEvent as recordAuditLog; // Aliases method from trait!
271+
}
272+
}
273+
274+
$service = new AuditService();
275+
276+
// Valid Call
277+
$service->recordAuditLog(1, 'audit_ok');
278+
279+
// Invalid Call ($level = -1 violates inherited Trait's @param positive-int)
280+
$service->recordAuditLog(-1, 'audit_ok');
281+
// Throws: TypeError: Argument $level must be of type positive-int
282+
```
283+
245284
---
246285

247286
## Partial Parameter Overriding (Gap-Filling)
@@ -292,42 +331,57 @@ $service->update(10, 'Charlie');
292331

293332
---
294333

295-
## Parameter Renaming ($id → $userId) & Position Shifts
334+
## Parameter Renaming ($id → $userId) & Position Shift Disambiguation
296335

297-
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**:
336+
When a child class, constructor, or trait implementation overrides an ancestor method, parameter positions may shift when new parameters are inserted, or parameter names may be renamed.
298337

299-
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.
300-
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.
338+
TypePHP resolves parameter contract inheritance using **3-Tier Name & Position Disambiguation**:
339+
340+
1. **Name-First Matching:** If a parameter name in the child method matches a parameter name in the parent class (e.g. `$container`), the parent's contract is mapped to that parameter regardless of its position index in the child.
341+
2. **Position Fallback on Renamed Parameters:** If a parameter is renamed in the child class (e.g., `$id` $\rightarrow$ `$userId`), TypePHP maps the contract using its position index.
342+
3. **Candidate Disambiguation (Shift Protection):** If a child class inserts a new parameter at index 0 (shifting all subsequent parameters down), TypePHP **verifies that the candidate child parameter does not already exist in the parent under its own name**. This prevents parent parameter contracts from accidentally mis-mapping onto shifted child parameters!
301343

302344
```php
303-
class BaseField
345+
class BaseRegistry
304346
{
305347
/**
306-
* Parent constructor has $api at position #1
348+
* Parent constructor has 3 params:
349+
* Index 0: $container
350+
* Index 1: $definitions
351+
* Index 2: $repositoryMap
307352
*
308-
* @param string $type
309-
* @param bool|array{admin-api: bool} $api
353+
* @param array<string, string> $definitions
354+
* @param array<string, string> $repositoryMap
310355
*/
311-
public function __construct(string $type, bool|array $api = false) {}
356+
public function __construct(
357+
ContainerInterface $container,
358+
array $definitions,
359+
array $repositoryMap
360+
) {}
312361
}
313362

314-
class OneToManyRelation extends BaseField
363+
class SalesChannelRegistry extends BaseRegistry
315364
{
316365
/**
317-
* Child inserts $entity, $ref, $onDelete BEFORE $api (position shift!)
366+
* Child inserts $prefix at Index 0 (shifting $container to Index 1),
367+
* and renames $definitions -> $definitionMap at Index 2!
368+
*
369+
* @param array<string, string> $definitionMap
370+
* @param array<string, string> $repositoryMap
318371
*/
319372
public function __construct(
320-
string $entity,
321-
string $ref,
322-
OnDeleteOption $onDelete = OnDeleteOption::NO_ACTION,
323-
bool|array $api = false
373+
string $prefix,
374+
ContainerInterface $container,
375+
array $definitionMap,
376+
array $repositoryMap
324377
) {
325-
parent::__construct('one-to-many', $api);
378+
parent::__construct($container, $definitionMap, $repositoryMap);
326379
}
327380
}
328381

329-
// $onDelete (position #2 in child) is NOT overwritten by $api's type (position #1 in parent)!
330-
$attr = new OneToManyRelation('unit', 'unit_id', OnDeleteOption::CASCADE, true);
382+
// TypePHP correctly keeps $container (Index 1 in child) untouched,
383+
// rather than mis-mapping parent's @param array $definitions (Index 1 in parent) onto it!
384+
new SalesChannelRegistry('sales_channel.', new Container(), ['prod' => 'ProductDef'], ['prod' => 'ProductRepo']);
331385
```
332386

333387
---

docs/core-concepts/function-contracts.md

Lines changed: 94 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ registerUser(-5, 'Alice', 'admin');
3636
> **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.
3737
3838
---
39+
3940
## PHP 8.0+ Named Arguments
4041

4142
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:
@@ -62,6 +63,7 @@ registerUser(age: 25, username: 'Alice', id: 42);
6263
registerUser(age: 25, username: 'Alice', id: -5);
6364
// Throws: TypeError: registerUser(): Argument $id must be of type positive-int, negative int (-5) given
6465
```
66+
6567
---
6668

6769
## Class Methods (Instance & Static)
@@ -189,29 +191,6 @@ getUserStatus(-10);
189191
190192
---
191193

192-
## Variadic Parameter Contracts
193-
194-
When a function or method accepts variadic arguments (`...$items`), TypePHP validates every element passed in the variadic argument list:
195-
196-
```php
197-
/**
198-
* @param positive-int ...$ids
199-
*/
200-
function deleteUsers(int ...$ids): void
201-
{
202-
// ...
203-
}
204-
205-
// Valid Call
206-
deleteUsers(10, 20, 30);
207-
208-
// Invalid Call (3rd variadic item violates positive-int)
209-
deleteUsers(10, 20, -5);
210-
// Throws: TypeError: deleteUsers(): Argument $ids[2] must be of type positive-int
211-
```
212-
213-
---
214-
215194
## Fluent `$this` Identity Returns
216195

217196
For fluent builder or service classes annotated with `@return $this`, TypePHP verifies strict object identity (`$result === $this`), preventing accidental instantiation of new instances:
@@ -247,6 +226,96 @@ $builder->cloneSelf();
247226

248227
---
249228

229+
## Late Static Binding Return Contracts (`@return static`)
230+
231+
When a parent class method (static factory method or fluent instance method) is annotated with `@return static`, TypePHP enforces **Late Static Binding** at runtime.
232+
233+
It dynamically verifies that the returned object is an instance of the **actual calling class** (`UserEntityFactory`), strictly rejecting parent instances (`BaseEntityFactory`), sibling instances (`AdminEntityFactory`), or generic objects (`stdClass`):
234+
235+
```php
236+
abstract class BaseEntityFactory
237+
{
238+
/**
239+
* @return static
240+
*/
241+
public static function create(): static
242+
{
243+
return new static();
244+
}
245+
246+
/**
247+
* @return static
248+
*/
249+
public static function createSibling(): object
250+
{
251+
return new AdminEntityFactory(); // Invalid: Returns sibling instead of calling class!
252+
}
253+
}
254+
255+
class UserEntityFactory extends BaseEntityFactory {}
256+
class AdminEntityFactory extends BaseEntityFactory {}
257+
258+
// Valid: Returns UserEntityFactory instance matching the late-static calling class
259+
$user = UserEntityFactory::create();
260+
261+
// Invalid: UserEntityFactory called, but AdminEntityFactory was returned!
262+
UserEntityFactory::createSibling();
263+
// Throws: TypeError: UserEntityFactory::createSibling(): Return value must be of type App\UserEntityFactory, App\AdminEntityFactory returned
264+
```
265+
266+
### Late Static Binding with Generics (`static<T>`)
267+
268+
Late static binding seamlessly integrates with TypePHP's Reified Generics engine. A static factory can return a specialized generic instance of the late-static-bound calling class:
269+
270+
```php
271+
/**
272+
* @template T
273+
*/
274+
abstract class BaseGenericFactory
275+
{
276+
/**
277+
* @template TValue
278+
* @param TValue $value
279+
* @return static<TValue>
280+
*/
281+
public static function of(mixed $value): static
282+
{
283+
return new static($value);
284+
}
285+
}
286+
287+
class UserGenericFactory extends BaseGenericFactory {}
288+
289+
// 1. Returns UserGenericFactory instance
290+
// 2. Binds generic template T = Dog in WeakMap memory!
291+
$factory = UserGenericFactory::of(new Dog());
292+
```
293+
294+
---
295+
296+
## Variadic Parameter Contracts
297+
298+
When a function or method accepts variadic arguments (`...$items`), TypePHP validates every element passed in the variadic argument list:
299+
300+
```php
301+
/**
302+
* @param positive-int ...$ids
303+
*/
304+
function deleteUsers(int ...$ids): void
305+
{
306+
// ...
307+
}
308+
309+
// Valid Call
310+
deleteUsers(10, 20, 30);
311+
312+
// Invalid Call (3rd variadic item violates positive-int)
313+
deleteUsers(10, 20, -5);
314+
// Throws: TypeError: deleteUsers(): Argument $ids[2] must be of type positive-int
315+
```
316+
317+
---
318+
250319
## Conditional Return Types
251320

252321
TypePHP supports parameter-based conditional return types (`@return ($param is true ? TypeA : TypeB)`):
@@ -270,13 +339,14 @@ formatValue(false, 'hello'); // Evaluates return type as non-empty-string
270339
formatValue(true, 'not_an_int');
271340
// Throws: TypeError: formatValue(): Return value must be of type positive-int
272341
```
342+
273343
---
274344

275345
## PHP 8.0+ Attributes Coexistence
276346

277347
TypePHP seamlessly coexists with native PHP 8.0+ Attributes (`#[Route]`, `#[Inject]`, `#[Validate]`).
278348

279-
You can place your PHPDoc annotations **either above or below** native PHP attributes on properties, methods/functions. TypePHP's AST engine and PHP's Reflection API process both metadata channels independently without any syntax conflicts:
349+
You can place your PHPDoc annotations **either above or below** native PHP attributes on properties, methods, or functions. TypePHP's AST engine and PHP's Reflection API process both metadata channels independently without any syntax conflicts:
280350

281351
```php
282352
// Option A: DocBlock ABOVE Attribute (Supported)

src/Internal/Checker/GeneratorChecker.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public static function checkSend(string $function, mixed $sendValue, TypeValidat
2929
if ($sendTypeNode !== null) {
3030
$err = $registry->validate($sendValue, $sendTypeNode, "$function(): Generator sent value (TSend)");
3131
if ($err !== null) {
32-
throw new \TypePHP\Exception\TypeError($err->getMessage());
32+
return $err;
3333
}
3434
}
3535
}
@@ -64,14 +64,14 @@ public static function checkYield(string $function, mixed $key, mixed $value, Ty
6464
if ($key !== null && $keyTypeNode !== null) {
6565
$err = $registry->validate($key, $keyTypeNode, "$function(): Return iterator key");
6666
if ($err !== null) {
67-
throw new \TypePHP\Exception\TypeError($err->getMessage());
67+
return $err;
6868
}
6969
}
7070

7171
if ($itemTypeNode !== null) {
7272
$err = $registry->validate($value, $itemTypeNode, "$function(): Return iterator value");
7373
if ($err !== null) {
74-
throw new \TypePHP\Exception\TypeError($err->getMessage());
74+
return $err;
7575
}
7676
}
7777

src/Internal/Checker/ParamChecker.php

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use TypePHP\Internal\TypeFormatter;
1818
use TypePHP\Resolver\SpecialTypeResolver;
1919
use TypePHP\Resolver\TemplateManager;
20+
use TypePHP\Resolver\TemplateSubstitutor;
2021
use TypePHP\Validator\TypeValidatorRegistry;
2122

2223
/**
@@ -27,17 +28,19 @@ final class ParamChecker
2728
/**
2829
* @param array<string, mixed> $vars
2930
*/
30-
public static function checkParams(string $function, array $vars, ?object $thisObj, TypeValidatorRegistry $registry): ?ErrorMessage
31+
public static function checkParams(string $function, array $vars, object|string|null $thisOrClass, TypeValidatorRegistry $registry): ?ErrorMessage
3132
{
3233
if (! (bool) (Config::get()['params'] ?? true)) {
3334
return null;
3435
}
3536

37+
$thisObj = \is_object($thisOrClass) ? $thisOrClass : null;
3638
$effectiveFunction = $function;
37-
if ($thisObj !== null && str_contains($function, '::')) {
39+
40+
if (str_contains($function, '::')) {
3841
[$classOrTrait, $methodName] = explode('::', $function, 2);
39-
$actualClassName = \get_class($thisObj);
40-
if ($actualClassName !== $classOrTrait) {
42+
$actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : null);
43+
if ($actualClassName !== null && $actualClassName !== $classOrTrait) {
4144
$effectiveFunction = $actualClassName . '::' . $methodName;
4245
}
4346
}
@@ -76,6 +79,9 @@ public static function checkParams(string $function, array $vars, ?object $thisO
7679
TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass);
7780
}
7881

82+
$boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $templates);
83+
$declaredTemplates = $templates;
84+
7985
foreach ($contract['types'] as $paramName => $typeNode) {
8086
if (! \array_key_exists($paramName, $vars)) {
8187
continue;
@@ -92,6 +98,18 @@ public static function checkParams(string $function, array $vars, ?object $thisO
9298
$typeNode = $aliases[$typeNode->name];
9399
}
94100

101+
$isClassStringT = ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates));
102+
103+
$isBareTemplate = ($typeNode instanceof IdentifierTypeNode && isset($templates[$typeNode->name]))
104+
|| ($typeNode instanceof ArrayTypeNode && $typeNode->type instanceof IdentifierTypeNode && isset($templates[$typeNode->type->name]));
105+
106+
$shouldSkipTemplateSub = $isBareTemplate || $isClassStringT;
107+
108+
if (! $shouldSkipTemplateSub && (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0)) {
109+
$typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $declaredTemplates);
110+
$typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj);
111+
}
112+
95113
if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) {
96114
$err = self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates);
97115
if ($err !== null) {

0 commit comments

Comments
 (0)