Skip to content

Resolve generic parameter defaults that reference other template parameters#5304

Merged
ondrejmirtes merged 1 commit intophpstan:2.1.xfrom
JanTvrdik:fix/generic-default-referring-other-template
Mar 26, 2026
Merged

Resolve generic parameter defaults that reference other template parameters#5304
ondrejmirtes merged 1 commit intophpstan:2.1.xfrom
JanTvrdik:fix/generic-default-referring-other-template

Conversation

@JanTvrdik
Copy link
Contributor

@JanTvrdik JanTvrdik commented Mar 26, 2026

Summary

When a template parameter has a default referencing another template parameter (e.g. @template DO of EI = EI), the default was left as an unresolved TemplateType instead of being substituted with the concrete type.

Root cause

TemplateTypeHelper::resolveTemplateTypes returns resolved values ($newType) without re-invoking the callback. When the standins map has DO → TemplateType(EI) and EI → MoneyValue, resolving DO yields TemplateType(EI) — but that is never resolved further to MoneyValue. This is because TypeTraverser's $traverse traverses inner types (bound, default) but does not re-invoke the callback on the type itself, so $traverse($newType) wouldn't help either.

Fix

Single-point fix in ClassReflection::typeMapFromList — the gateway where all type arguments enter the standins map (active template type map). After building the map, any entry that is a TemplateType referencing another entry in the same class scope is resolved to that entry's concrete type.

Why typeMapFromList and not elsewhere:

  • It's the single point through which all standins flow (via GenericObjectType::getClassReflection()withTypes())
  • No changes needed in TypeNodeResolver, resolveTemplateTypes, or FileTypeMapper
  • Minimal, 8-line change — just a map lookup, no TypeTraverser needed
  • No risk to existing resolveTemplateTypes behavior

Example

/**
 * @template-contravariant DI
 * @template-contravariant EI
 * @template-covariant DO of EI = EI
 * @template-covariant EO of DI = DI
 */
interface Codec {
    /** @param DI $data @return DO */
    public function decode(mixed $data): mixed;
    /** @param EI $data @return EO */
    public function encode(mixed $data): mixed;
}

/** @param Codec<array{currency: string, cents: int}, MoneyValue> $codec */
function test(Codec $codec): void {
    $codec->decode([...]); // Before: EI (unresolved), After: MoneyValue
    $codec->encode(new MoneyValue(...)); // Before: DI (unresolved), After: array{currency: string, cents: int}
}

Test plan

  • New test tests/PHPStan/Analyser/nsrt/template-default-referring-other.php passes
  • All existing template-default tests pass
  • Full NodeScopeResolverTest (1480 tests) — no regressions
  • Rules tests (2917 tests) — no regressions
  • Generics rules tests (41 tests) — all pass

Co-Authored-By: Claude Code

Copilot AI review requested due to automatic review settings March 26, 2026 12:13
@phpstan-bot
Copy link
Collaborator

You've opened the pull request against the latest branch 2.2.x. PHPStan 2.2 is not going to be released for months. If your code is relevant on 2.1.x and you want it to be released sooner, please rebase your pull request and change its target to 2.1.x.

@JanTvrdik JanTvrdik force-pushed the fix/generic-default-referring-other-template branch from 8f929b8 to dd69bbf Compare March 26, 2026 12:16
@JanTvrdik JanTvrdik changed the base branch from 2.2.x to 2.1.x March 26, 2026 12:16
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes generic template default resolution when a template parameter’s default references another template parameter, ensuring defaults are substituted with the concrete provided types during PHPDoc generic parsing and class reflection mapping.

Changes:

  • Resolve template defaults that reference other template parameters in TypeNodeResolver when fewer type args are provided than template params.
  • Add defensive substitution of such defaults in ClassReflection::typeMapFromList() / typeMapToList().
  • Add an NSRT test covering Codec<DI, EI, DO of EI = EI, EO of DI = DI> with partial and full type argument lists.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
tests/PHPStan/Analyser/nsrt/template-default-referring-other.php Adds coverage for template defaults referencing other template params with partial/full generic args.
src/Reflection/ClassReflection.php Substitutes defaults referencing other template params when converting between type lists and template maps.
src/PhpDoc/TypeNodeResolver.php Substitutes template-default types that reference other template params during generic type resolution.
Comments suppressed due to low confidence (1)

src/PhpDoc/TypeNodeResolver.php:887

  • Default-type substitution is only applied in a single pass using a $resolveMap built before any substitutions. If a template default refers to another template param whose value itself comes from a default (or from another substitution), the nested reference can remain unresolved. Consider resolving defaults iteratively (or rebuilding the resolve map as defaults are resolved) so chained references between template defaults are fully substituted.

					for ($i = $providedCount; $i < count($genericTypes); $i++) {
						$genericTypes[$i] = TypeTraverser::map($genericTypes[$i], static function (Type $type, callable $traverse) use ($resolveMap, $mainTypeClassName): Type {
							if ($type instanceof TemplateType && $type->getScope()->getClassName() === $mainTypeClassName && isset($resolveMap[$type->getName()])) {
								return $resolveMap[$type->getName()];
							}
							return $traverse($type);
						});
					}
				}

				if (in_array($mainTypeClassName, [
					Traversable::class,
					IteratorAggregate::class,
					Iterator::class,
				], true)) {
					if (count($genericTypes) === 1) {
						return new GenericObjectType($mainTypeClassName, [
							new MixedType(true),

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@JanTvrdik JanTvrdik force-pushed the fix/generic-default-referring-other-template branch 3 times, most recently from b2554dd to 93dd44c Compare March 26, 2026 12:41
@ondrejmirtes
Copy link
Member

I don't love this logic, I feel like this should be coming out clean out of FileTypeMapper/PhpDocNodeResolver (the correct default type being in TemplateTag).

I have a couple of PRs that tried to achieve something similar (#4753 + #4752) but they're not finished.

@JanTvrdik JanTvrdik force-pushed the fix/generic-default-referring-other-template branch from 93dd44c to 2461ca9 Compare March 26, 2026 14:31
@JanTvrdik
Copy link
Contributor Author

@ondrejmirtes Claude has considered your comment and has chosen much simpler approach.

…meters

When a template parameter has a default referencing another template
parameter (e.g. `@template DO of EI = EI`), the default was left as
an unresolved TemplateType instead of being substituted with the
concrete type provided for the referenced parameter.

The root cause is that resolveTemplateTypes returns resolved values
without re-invoking the callback, so transitive TemplateType
references in the standins map are never resolved.

Fixed in ClassReflection::typeMapFromList — the single gateway where
all type arguments enter the standins map. After building the map,
any entry that is a TemplateType referencing another entry in the
same class is resolved to that entry's concrete type.

Co-Authored-By: Claude Code
@JanTvrdik JanTvrdik force-pushed the fix/generic-default-referring-other-template branch from 2461ca9 to 4281e33 Compare March 26, 2026 14:53
@ondrejmirtes
Copy link
Member

Perfect 👍

@ondrejmirtes ondrejmirtes merged commit 38740d0 into phpstan:2.1.x Mar 26, 2026
652 of 654 checks passed
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.

4 participants