Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
docs/.vitepress/cache
docs/.vitepress/dist
/manual-tests
composer.lock
composer.lock
index.php
90 changes: 72 additions & 18 deletions docs/advanced/liskov-and-inheritance.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ $model->setTraitId(-50);
AppModel::setTraitVersion('');
// Throws: TypeError: Property AppModel::$traitVersion must be of type non-empty-string
```

---

## Trait Inheritance Across Parent-Child Classes

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:
Expand Down Expand Up @@ -242,6 +244,43 @@ $child->logMessage(10, 'boot');
$child->logMessage(-50, 'boot');
// Throws: TypeError: ChildService::logMessage(): Argument $level must be of type positive-int
```

---

## Trait Method Aliasing (`use Trait { oldMethod as newMethod; }`)

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:

```php
trait LoggerTrait
{
/**
* @param positive-int $level
* @param non-empty-string $message
*/
public function logEvent(int $level, string $message): bool
{
return true;
}
}

class AuditService
{
use LoggerTrait {
logEvent as recordAuditLog; // Aliases method from trait!
}
}

$service = new AuditService();

// Valid Call
$service->recordAuditLog(1, 'audit_ok');

// Invalid Call ($level = -1 violates inherited Trait's @param positive-int)
$service->recordAuditLog(-1, 'audit_ok');
// Throws: TypeError: Argument $level must be of type positive-int
```

---

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

---

## Parameter Renaming ($id → $userId) & Position Shifts
## Parameter Renaming ($id → $userId) & Position Shift Disambiguation

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**:
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.

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.
TypePHP resolves parameter contract inheritance using **3-Tier Name & Position Disambiguation**:

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.
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.
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!

```php
class BaseField
class BaseRegistry
{
/**
* Parent constructor has $api at position #1
* Parent constructor has 3 params:
* Index 0: $container
* Index 1: $definitions
* Index 2: $repositoryMap
*
* @param string $type
* @param bool|array{admin-api: bool} $api
* @param array<string, string> $definitions
* @param array<string, string> $repositoryMap
*/
public function __construct(string $type, bool|array $api = false) {}
public function __construct(
ContainerInterface $container,
array $definitions,
array $repositoryMap
) {}
}

class OneToManyRelation extends BaseField
class SalesChannelRegistry extends BaseRegistry
{
/**
* Child inserts $entity, $ref, $onDelete BEFORE $api (position shift!)
* Child inserts $prefix at Index 0 (shifting $container to Index 1),
* and renames $definitions -> $definitionMap at Index 2!
*
* @param array<string, string> $definitionMap
* @param array<string, string> $repositoryMap
*/
public function __construct(
string $entity,
string $ref,
OnDeleteOption $onDelete = OnDeleteOption::NO_ACTION,
bool|array $api = false
string $prefix,
ContainerInterface $container,
array $definitionMap,
array $repositoryMap
) {
parent::__construct('one-to-many', $api);
parent::__construct($container, $definitionMap, $repositoryMap);
}
}

// $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);
// TypePHP correctly keeps $container (Index 1 in child) untouched,
// rather than mis-mapping parent's @param array $definitions (Index 1 in parent) onto it!
new SalesChannelRegistry('sales_channel.', new Container(), ['prod' => 'ProductDef'], ['prod' => 'ProductRepo']);
```

---
Expand Down
118 changes: 94 additions & 24 deletions docs/core-concepts/function-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ 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:
Expand All @@ -62,6 +63,7 @@ registerUser(age: 25, username: 'Alice', id: 42);
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)
Expand Down Expand Up @@ -189,29 +191,6 @@ getUserStatus(-10);

---

## Variadic Parameter Contracts

When a function or method accepts variadic arguments (`...$items`), TypePHP validates every element passed in the variadic argument list:

```php
/**
* @param positive-int ...$ids
*/
function deleteUsers(int ...$ids): void
{
// ...
}

// Valid Call
deleteUsers(10, 20, 30);

// Invalid Call (3rd variadic item violates positive-int)
deleteUsers(10, 20, -5);
// Throws: TypeError: deleteUsers(): Argument $ids[2] must be of type positive-int
```

---

## Fluent `$this` Identity Returns

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

---

## Late Static Binding Return Contracts (`@return static`)

When a parent class method (static factory method or fluent instance method) is annotated with `@return static`, TypePHP enforces **Late Static Binding** at runtime.

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`):

```php
abstract class BaseEntityFactory
{
/**
* @return static
*/
public static function create(): static
{
return new static();
}

/**
* @return static
*/
public static function createSibling(): object
{
return new AdminEntityFactory(); // Invalid: Returns sibling instead of calling class!
}
}

class UserEntityFactory extends BaseEntityFactory {}
class AdminEntityFactory extends BaseEntityFactory {}

// Valid: Returns UserEntityFactory instance matching the late-static calling class
$user = UserEntityFactory::create();

// Invalid: UserEntityFactory called, but AdminEntityFactory was returned!
UserEntityFactory::createSibling();
// Throws: TypeError: UserEntityFactory::createSibling(): Return value must be of type App\UserEntityFactory, App\AdminEntityFactory returned
```

### Late Static Binding with Generics (`static<T>`)

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:

```php
/**
* @template T
*/
abstract class BaseGenericFactory
{
/**
* @template TValue
* @param TValue $value
* @return static<TValue>
*/
public static function of(mixed $value): static
{
return new static($value);
}
}

class UserGenericFactory extends BaseGenericFactory {}

// 1. Returns UserGenericFactory instance
// 2. Binds generic template T = Dog in WeakMap memory!
$factory = UserGenericFactory::of(new Dog());
```

---

## Variadic Parameter Contracts

When a function or method accepts variadic arguments (`...$items`), TypePHP validates every element passed in the variadic argument list:

```php
/**
* @param positive-int ...$ids
*/
function deleteUsers(int ...$ids): void
{
// ...
}

// Valid Call
deleteUsers(10, 20, 30);

// Invalid Call (3rd variadic item violates positive-int)
deleteUsers(10, 20, -5);
// Throws: TypeError: deleteUsers(): Argument $ids[2] must be of type positive-int
```

---

## Conditional Return Types

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

---

## PHP 8.0+ Attributes Coexistence

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

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:
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:

```php
// Option A: DocBlock ABOVE Attribute (Supported)
Expand Down
6 changes: 3 additions & 3 deletions src/Internal/Checker/GeneratorChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public static function checkSend(string $function, mixed $sendValue, TypeValidat
if ($sendTypeNode !== null) {
$err = $registry->validate($sendValue, $sendTypeNode, "$function(): Generator sent value (TSend)");
if ($err !== null) {
throw new \TypePHP\Exception\TypeError($err->getMessage());
return $err;
}
}
}
Expand Down Expand Up @@ -64,14 +64,14 @@ public static function checkYield(string $function, mixed $key, mixed $value, Ty
if ($key !== null && $keyTypeNode !== null) {
$err = $registry->validate($key, $keyTypeNode, "$function(): Return iterator key");
if ($err !== null) {
throw new \TypePHP\Exception\TypeError($err->getMessage());
return $err;
}
}

if ($itemTypeNode !== null) {
$err = $registry->validate($value, $itemTypeNode, "$function(): Return iterator value");
if ($err !== null) {
throw new \TypePHP\Exception\TypeError($err->getMessage());
return $err;
}
}

Expand Down
26 changes: 22 additions & 4 deletions src/Internal/Checker/ParamChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use TypePHP\Internal\TypeFormatter;
use TypePHP\Resolver\SpecialTypeResolver;
use TypePHP\Resolver\TemplateManager;
use TypePHP\Resolver\TemplateSubstitutor;
use TypePHP\Validator\TypeValidatorRegistry;

/**
Expand All @@ -27,17 +28,19 @@ final class ParamChecker
/**
* @param array<string, mixed> $vars
*/
public static function checkParams(string $function, array $vars, ?object $thisObj, TypeValidatorRegistry $registry): ?ErrorMessage
public static function checkParams(string $function, array $vars, object|string|null $thisOrClass, TypeValidatorRegistry $registry): ?ErrorMessage
{
if (! (bool) (Config::get()['params'] ?? true)) {
return null;
}

$thisObj = \is_object($thisOrClass) ? $thisOrClass : null;
$effectiveFunction = $function;
if ($thisObj !== null && str_contains($function, '::')) {

if (str_contains($function, '::')) {
[$classOrTrait, $methodName] = explode('::', $function, 2);
$actualClassName = \get_class($thisObj);
if ($actualClassName !== $classOrTrait) {
$actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : null);
if ($actualClassName !== null && $actualClassName !== $classOrTrait) {
$effectiveFunction = $actualClassName . '::' . $methodName;
}
}
Expand Down Expand Up @@ -76,6 +79,9 @@ public static function checkParams(string $function, array $vars, ?object $thisO
TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass);
}

$boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $templates);
$declaredTemplates = $templates;

foreach ($contract['types'] as $paramName => $typeNode) {
if (! \array_key_exists($paramName, $vars)) {
continue;
Expand All @@ -92,6 +98,18 @@ public static function checkParams(string $function, array $vars, ?object $thisO
$typeNode = $aliases[$typeNode->name];
}

$isClassStringT = ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates));

$isBareTemplate = ($typeNode instanceof IdentifierTypeNode && isset($templates[$typeNode->name]))
|| ($typeNode instanceof ArrayTypeNode && $typeNode->type instanceof IdentifierTypeNode && isset($templates[$typeNode->type->name]));

$shouldSkipTemplateSub = $isBareTemplate || $isClassStringT;

if (! $shouldSkipTemplateSub && (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0)) {
$typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $declaredTemplates);
$typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj);
}

if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) {
$err = self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates);
if ($err !== null) {
Expand Down
Loading
Loading