Skip to content

Commit 8fc84e5

Browse files
committed
Propagate constructor-inferred generics through chained instantiation
1 parent 23ee411 commit 8fc84e5

10 files changed

Lines changed: 827 additions & 50 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333

3434
### Fixed
3535

36-
- **`@method` tag resolution.** Colon return type syntax (`@method foo(): bool`), parenthesised return types (`@method (callable():string) foo()`, `@method (string|int)[] bar()`), and the ambiguous single-`static` pattern (`@method static getStatic()`) are now parsed correctly. Template parameters in `@method` return types are substituted through `@extends` and `@implements` annotations (e.g. `@method T get()` on a parent class resolves to the concrete type when a child declares `@extends Parent<string>`).
36+
- **Chained instantiation preserves constructor-inferred generics.** Expressions like `(new Box(new Product()))->get()` and `new Box(new Product())->get()` now propagate template arguments inferred from constructor parameters to subsequent method calls, so `get()` returns the concrete type instead of `mixed`.
37+
- **`@method` tag resolution.** Colon return type syntax (`@method foo(): bool`), parenthesised return types (`@method (callable():string) foo()`, `@method (string|int)[] bar()`), and the ambiguous single-`static` pattern (`@method static getStatic()`) are now parsed correctly. Template parameters in `@method` return types are substituted through `@extends` and `@implements` annotations (e.g. `@method T get()` on a parent class resolves to the concrete type when a child declares `@extends Parent<string>`). `$this` return types on `@method` tags preserve generic arguments (e.g. `A<B>` when the receiver is `A<B>`).
3738

3839
- **First-class callable invocation return types.** Immediately invoking a first-class callable (`Foo::method(...)()`, `$this->method(...)()`, `self::method(...)()`, `static::method(...)()`, `parent::method(...)()`, `func(...)()`) now resolves to the underlying method or function's return type. Previously these expressions returned no type.
3940
- **`isset()` and `empty()` narrowing.** `isset($x)` in a condition now strips `null` from the variable's type in the truthy branch (matching `$x !== null` semantics). `!isset($x)` narrows to `null` only. `!empty($x)` strips `null` from nullable types (e.g. `string|null` narrows to `string`). Both simple variables and property access (`$obj->prop`) are supported, and multiple arguments (`isset($a, $b)`) narrow all listed variables. Guard clauses (`if (!isset($x)) { return; }`) strip null from the variable in the code that follows.
@@ -49,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4950
- **Stack overflow on large codebases and large files.** The `analyze` command and files with hundreds of class definitions no longer crash with stack overflows.
5051
- **`analyze` and `fix` commands run at consistent speed regardless of invocation style.** Running from within a project directory is no longer ~8x slower than using `--project-root`.
5152
- **Type narrowing.** Comprehensive fixes to type narrowing: non-exiting `if` branches no longer leak narrowed types into post-merge scope; `is_float()`, `is_null()`, and other `is_*()` guards correctly narrow multi-member unions; negated `instanceof` on nullable unions preserves `null`; `is_object()` narrows to all class types in a union (not just the first); `instanceof` on `mixed` or `object` narrows to the checked type; `=== null` and `== null` narrow to `null` in the truthy branch; `assert($x instanceof Foo)` followed by any `if` block no longer loses the narrowed type; falsy guard clauses strip both `false` and `null`; template-constrained types are expanded before guard filtering. `is_numeric()`, `is_bool()`, and `is_scalar()` guard clauses now narrow union types correctly. `exit` and `die` are recognized as unconditional exits for guard clause narrowing. Property access expressions (`$a->foo`) are now narrowed through chained if/elseif/else conditions using `is_string()`, `instanceof`, and other type guards. Array shape keys are now narrowed through null-check guard clauses and conditional reassignment (e.g. `$a["test"]` resolves to `int` instead of `?int` after `if ($a["test"] === null) { return; }`). After `instanceof` check plus reassignment inside the if-body, the post-merge type collapses to the common parent class instead of producing a redundant `Child|Parent` union.
52-
- **Generics.** Closure literals passed to `@template` parameters are recognised as `Closure`. Class-level template parameters are preserved through chained method calls. Template parameters fall back to their declared bound (or `mixed` when unbounded) when subclasses omit `@extends` or `@use` annotations. Unbound template parameters at call sites resolve to their declared bounds or `mixed` instead of leaking raw names. Method-level templates resolve correctly through generic wrappers and nested call chains. Return type generic arguments are preserved for template substitution, fixing false "expects TRelatedModel, got Translation" diagnostics. Iterating over a subclass that extends a generic collection with scalar type arguments (e.g. `IntCollection extends Collection<int, int>`) now yields the concrete scalar type instead of the raw template parameter name.
53+
- **Generics.** Constructor generic inference now works through inherited constructors: child classes without their own constructor infer template parameters from the parent's constructor arguments, with correct remapping through multi-level `@extends` chains (including swapped or renamed template parameters). Function-level `@template` parameters bound to generic wrapper types (e.g. `@param Container<TItem> $c`) are inferred from arguments that extend the wrapper class. Method calls are now case-insensitive, matching PHP semantics (e.g. `$obj->getId()` finds `getID()`). Closure literals passed to `@template` parameters are recognised as `Closure`. Class-level template parameters are preserved through chained method calls. Template parameters fall back to their declared bound (or `mixed` when unbounded) when subclasses omit `@extends` or `@use` annotations. Unbound template parameters at call sites resolve to their declared bounds or `mixed` instead of leaking raw names. Method-level templates resolve correctly through generic wrappers and nested call chains. Return type generic arguments are preserved for template substitution, fixing false "expects TRelatedModel, got Translation" diagnostics. Iterating over a subclass that extends a generic collection with scalar type arguments (e.g. `IntCollection extends Collection<int, int>`) now yields the concrete scalar type instead of the raw template parameter name.
5354
- **Vendor functions and constants.** Functions and constants defined in vendor packages are now indexed at startup, eliminating false-positive "Function not found" diagnostics.
5455
- **Use-imported classes no longer shadowed by global-namespace stubs.** The use-map is now checked first for unqualified names, fixing Laravel Facade static method resolution.
5556
- **Same-name class in a different namespace no longer shadows inherited members.** Parent resolution no longer picks up a same-named class from the current namespace instead of the intended global-namespace parent.

docs/todo/bugs.md

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,30 @@ to second-guess upstream output.
1111

1212

1313

14-
## B13. Template substitution through multi-level `@extends` chains
14+
## B13. Remaining template inference gaps
1515

1616
**Discovered:** SKIP audit of
1717
`tests/psalm_assertions/template_class_template_extends.php`.
1818

19-
Template parameters are not resolved through certain inheritance
20-
patterns:
21-
22-
- Function-level `@template` not substituted from concrete argument
23-
types at call sites
24-
- Two-level `@template-extends` chains (grandchild → child → parent)
25-
lose substitution
26-
- `@extends Pair<TValue, string>` where the child swaps parameter
27-
order maps them incorrectly
28-
- Constructor generic inference not propagated to child classes that
29-
lack their own constructor
30-
- `@template-implements IteratorAggregate<int, Foo>` not used to
31-
infer `getIterator()` return type
32-
- `ArrayObject::getIterator` not substituting template params from
33-
`@template-extends`
34-
- `__get` magic method not applying template substitution
35-
- Literal `false` widened to `bool` when used as template argument
19+
Constructor generic inference through inherited constructors,
20+
case-insensitive method lookup, and function-level `@template`
21+
inference through generic wrapper params are now fixed.
22+
Remaining gaps:
23+
24+
- **Function name resolution in multi-namespace files**: bare
25+
function names in namespaced code are not resolved to their FQN,
26+
so function-level template inference fails in single-file tests
27+
with multiple namespaces. Works correctly in real projects.
28+
- **`array<TKey, TValue>` constructor inference**: multi-arg array
29+
generic params in constructors (e.g. `@param array<TKey,TValue> $kv`)
30+
do not infer key/value types separately.
31+
- **`key-of<T>` and indexed access types** (`T[K]`): advanced type
32+
operators not yet supported.
33+
- **Literal `false` preserved as template argument**: currently
34+
widened to `bool`.
3635

3736
**Tests:** SKIPs in `tests/psalm_assertions/template_class_template_extends.php`
38-
(lines 177, 227, 266, 311, 358, 427, 500, 681-682, 737-738,
39-
770, 802, 843, 980-981, 1083-1084, 1087).
37+
(lines 177, 227, 427, 500, 681-682, 737-738, 843 (namespace), 1087).
4038

4139

4240
## B14. Template/generic resolution in namespace-level and complex scenarios

example.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,9 @@ public function demo(): void
543543

544544
$mapper->wrap(new Product())->first()->getPrice(); // new expression arg → Product
545545

546+
// Chained instantiation preserves constructor-inferred generics
547+
(new ObjectMapper())->wrap(new Pen())->first()->write(); // (new ...)->method() chain with generics
548+
546549
// Variadic class-string<T> → union return type
547550
$locator2 = new ServiceLocator();
548551
$union = $locator2->getAny(Pen::class, Marker::class);

0 commit comments

Comments
 (0)