Skip to content

Skip conditional type search on types without late-resolvable types - #6672

Open
Kocal wants to merge 1 commit into
phpstan:2.3.xfrom
Kocal:conditional-return-type-skip-traversal
Open

Kocal wants to merge 1 commit into
phpstan:2.3.xfrom
Kocal:conditional-return-type-skip-traversal

Conversation

@Kocal

@Kocal Kocal commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

ConditionalReturnTypeRuleHelper::check() walks every parameter type, out type, closure-this type and the return type with TypeTraverser::map() to collect ConditionalType and ConditionalTypeForParameter instances. Both are late-resolvable types, so a type whose hasTemplateOrLateResolvableType() is false can't contain one, and the traversal now gets skipped for those. When it does run, nothing changes: it still collects every occurrence without memoization, so this is independent from #6652, which keeps map() here on purpose.

On the symfony/ux-css array shape (847 keys, 144 of them a nested shape alias), this traversal visits the shared nested shape once per offset using it. With #6671, hasTemplateOrLateResolvableType() is memoized on ConstantArrayType, so the check costs almost nothing there.

For every Type whose traverse() visits children, hasTemplateOrLateResolvableType() checks those children too, except callable/closure parameter default values and ObjectWithoutClassType's subtracted type, neither of which can hold a conditional type in a declared signature. I logged every traversal that found a conditional type while hasTemplateOrLateResolvableType() was false; that gave 0 cases over the full test suite and PHPStan's self-analysis.

Measured with the https://github.com/Kocal/sf-ux-css-phpstan-reproducer reproducer, phpstan-nested.neon, --debug, 6 interleaved runs with the first discarded:

before after
2.3.x, no turbo 12.50-12.61 s 12.12-12.17 s
2.3.x + #6652 + #6671, no turbo 2.58-2.65 s 2.11-2.13 s
2.3.x, turbo 5.65-5.84 s 5.46-5.50 s

make tests passes with and without the turbo extension, make phpstan and make cs pass.

Refs phpstan/phpstan#15348

`ConditionalReturnTypeRuleHelper::check()` walks every parameter type, out type, closure-this type and the return type with `TypeTraverser::map()` to collect `ConditionalType` and `ConditionalTypeForParameter` instances. Both are late-resolvable types, so a type whose `hasTemplateOrLateResolvableType()` is false can't contain one, and the traversal now gets skipped for those. When it does run, nothing changes: it still collects every occurrence without memoization, so this is independent from phpstan#6652, which keeps `map()` here on purpose.

On the symfony/ux-css array shape (847 keys, 144 of them a nested shape alias), this traversal visits the shared nested shape once per offset using it. With phpstan#6671, `hasTemplateOrLateResolvableType()` is memoized on `ConstantArrayType`, so the check costs almost nothing there.

For every `Type` whose `traverse()` visits children, `hasTemplateOrLateResolvableType()` checks those children too, except callable/closure parameter default values and `ObjectWithoutClassType`'s subtracted type, neither of which can hold a conditional type in a declared signature. I logged every traversal that found a conditional type while `hasTemplateOrLateResolvableType()` was false; that gave 0 cases over the full test suite and PHPStan's self-analysis.

Measured with the https://github.com/Kocal/sf-ux-css-phpstan-reproducer reproducer, `phpstan-nested.neon`, `--debug`, 6 interleaved runs with the first discarded:

| | before | after |
|---|---|---|
| 2.3.x, no turbo | 12.50-12.61 s | 12.12-12.17 s |
| 2.3.x + phpstan#6652 + phpstan#6671, no turbo | 2.58-2.65 s | 2.11-2.13 s |
| 2.3.x, turbo | 5.65-5.84 s | 5.46-5.50 s |

`make tests` passes with and without the turbo extension, `make phpstan` and `make cs` pass.

Refs phpstan/phpstan#15348
@Kocal
Kocal force-pushed the conditional-return-type-skip-traversal branch from c0b434e to 31f3e3e Compare October 6, 2026 07:55
Kocal added a commit to Kocal/phpstan-src that referenced this pull request Oct 6, 2026
`GenericObjectTypeCheck::getGenericTypes()` walks the whole PHPDoc type with `TypeTraverser::map()` to collect every `GenericObjectType` and `GenericStaticType`. It now returns early when the type references no class (`getReferencedClasses() === []`) and `hasTemplateOrLateResolvableType()` is false. A generic object type always references its class, so the first check alone would miss nothing from that side. The second condition covers what `getReferencedClasses()` does not see: a template type's default (e.g. `@template T = Box<int, string, bool>`, then `@param T`) and late-resolvable types; both make `hasTemplateOrLateResolvableType()` true. When the traversal does run, nothing changes: it still collects every occurrence with `map()`, so this is independent from phpstan#6652.

This matters on the symfony/ux-css array shape (847 keys, 144 of them a nested shape alias, no class anywhere), where the traversal was visiting the shared nested shape once per offset using it. With phpstan#6671, both `getReferencedClasses()` and `hasTemplateOrLateResolvableType()` are memoized on `ConstantArrayType`, so the check is nearly free there.

`IncompatiblePhpDocTypeRuleTest::testGenericObjectTypeInTemplateDefault` covers the template default case. It passes before and after this change, and fails if the guard only checks `getReferencedClasses()`.

As a safety check, I temporarily logged every traversal that found a generic type while the guard would have skipped it: 0 cases out of 1623 traversals that found one, over the full test suite and PHPStan's self-analysis.

Numbers from https://github.com/Kocal/sf-ux-css-phpstan-reproducer with `phpstan-nested.neon`, `--debug`, 6 interleaved runs, first discarded:

| Scenario | Before | After |
| --- | --- | --- |
| 2.3.x without turbo | 12.98-13.21 s | 12.79-12.85 s |
| 2.3.x + phpstan#6652 + phpstan#6671 + phpstan#6672 without turbo | 2.14-2.26 s | 1.66-1.71 s |
| 2.3.x with turbo | 5.90-6.07 s | 5.65-5.69 s |

On ordinary code the guard costs nothing measurable: PHPStan analysing its own `src/Type` (about 28 s) calls it 683 times, the guard costs about 1 ms in total and skips about 0.75 ms of traversal.

`make tests` passes with and without the turbo extension, `make phpstan` and `make cs` pass.

Refs phpstan/phpstan#15348
Kocal added a commit to Kocal/phpstan-src that referenced this pull request Oct 6, 2026
…e's key index

In `ConstantArrayType::recursiveHasOffsetValueType()`, a constant offset (`ConstantStringType` / `ConstantIntegerType`) whose value isn't in the key index used to fall through to a scan calling `isSuperTypeOf()` on every key of the array. A non-template constant key can only answer "no" there, since its `isSuperTypeOf()` compares values of the same class, so those keys are now skipped. Template keys are still compared, and the unsealed-extras handling after the scan is unchanged. The turbo-ext mirror (`ConstantArrayType.cpp`) gets the same change.

This matters when a literal array is passed to a parameter typed with a large shape, like the symfony/ux-css `CssStyles` shape (847 keys): `checkOurKeys()` asks the literal array's `hasOffsetValueType()` for every key of the shape, and each miss used to compare against every key of the literal array.

As a safety check, I temporarily logged the result of every skipped comparison over the full test suite and PHPStan's self-analysis: 5.6M skipped comparisons, all of them "no".

The baseline grows by one more `instanceof ConstantStringType` in `ConstantArrayType.php` (5 -> 6), on purpose: the skip relies on the exact class, since only then does `isSuperTypeOf()` compare values.

Benchmarked with 50 calls to `CssRuntime::css()` using literal arrays against the nested shape from the [reproducer](https://github.com/Kocal/sf-ux-css-phpstan-reproducer), `--debug`, interleaved runs:

| Build | Before | After |
| --- | --- | --- |
| On top of phpstan#6652, phpstan#6671, phpstan#6672, phpstan#6673 + the `FunctionCallParametersCheck` reordering from the sibling PR (no turbo) | 1.56-1.59 s | 1.43-1.49 s |
| Plain 2.3.x (no turbo) | 1 min 12 s | 1 min 12 s |
| Plain 2.3.x (turbo) | 22.11-22.68 s | 22.07-22.55 s |
| PHPStan analysing its own `src/Type` | 28.13-28.29 s | 28.08-28.71 s |

The gain only shows up on top of the other PRs: elsewhere the walks they remove dominate.

`make tests` passes with and without the extension, `make phpstan` and `make cs` pass, turbo's `smoke.php`, `side-by-side.php` and `signature-parity.php` pass, and `--error-format=raw` output is byte-identical with the extension on and off.

Refs phpstan/phpstan#15348
Kocal added a commit to Kocal/phpstan-src that referenced this pull request Oct 6, 2026
…s one

In `FunctionCallParametersCheck::check()`, each argument's resolved parameter type and original parameter type were both passed to `UnresolvableTypeHelper::getUnresolvableType()`, and only afterwards did the code check whether the resolved type actually had an unresolvable type. The original type is only reported as an error when the resolved type has an unresolvable type and the original one does not, so walking the original type was wasted work whenever the resolved type was fine. This reorders the check to walk the resolved type first and only then the original type, with the same reordering applied to the return type check a few lines below. Both calls are pure, so the reported errors are unchanged.

`getUnresolvableType()` walks the whole type, which gets expensive on large array shapes. The `CssStyles` shape from symfony/ux-css has 847 keys, 144 of them a nested shape alias; every call site using it as a parameter type paid for two full walks of that shape, and the second one was almost never needed. TwigStan, which compiles templates to PHP, would produce such calls for every `css()` in a template.

Benchmark: 50 calls to `CssRuntime::css()` with literal arrays (3 to 8 keys, up to two nested conditions like `_hover` or `md`) spread over 10 classes, analysed against the reproducer's nested shape ([sf-ux-css-phpstan-reproducer](https://github.com/Kocal/sf-ux-css-phpstan-reproducer), `phpstan-nested.neon`), `--debug`, interleaved runs:

| Scenario | Before | After |
|---|---|---|
| 2.3.x, no turbo | 1 min 12 s | 48.5-49.1 s |
| 2.3.x, turbo | 21.99-22.16 s | 14.43-14.54 s |
| 2.3.x + phpstan#6652 + phpstan#6671 + phpstan#6672 + phpstan#6673, no turbo | 2.05-2.11 s | 1.74-1.75 s |

`make tests` passes with and without the turbo extension, `make phpstan` and `make cs` pass.

Refs phpstan/phpstan#15348
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants