Skip to content

Generics on phpstan-type aliases#5378

Draft
shmax wants to merge 25 commits intophpstan:2.1.xfrom
shmax:generics-on-phpstan-types
Draft

Generics on phpstan-type aliases#5378
shmax wants to merge 25 commits intophpstan:2.1.xfrom
shmax:generics-on-phpstan-types

Conversation

@shmax
Copy link
Copy Markdown

@shmax shmax commented Apr 1, 2026

Fix for phpstan/phpstan#6099 phpstan/phpstan#12730

Generic @phpstan-type Aliases

Summary

This PR adds support for parameterised (generic) @phpstan-type aliases — local type aliases that accept template type arguments, much like how @template works on classes and functions today.

A generic alias lets you write a reusable structural type once and instantiate it with different concrete types at each use site, without repeating the full shape definition everywhere.


Motivation

PHPStan already supports template types on classes (@template T) and on individual functions/methods. But @phpstan-type aliases have always been monomorphic — every use site gets the exact same concrete type. That makes it impossible to express patterns like:

/**
 * @phpstan-type Paged array{items: list<mixed>, total: int}
 */

You can't make items work generically; you end up duplicating the alias for every element type, or reaching for a full class + @template.

This PR closes that gap.


Syntax

Template parameters on an alias follow the same @template syntax you already know:

/**
 * @phpstan-type Paged<TItem of object> array{items: list<TItem>, total: int}
 * @phpstan-type Map<TKey of array-key, TValue> array<TKey, TValue>
 * @phpstan-type WithDefault<T = string> array{value: T}
 * @phpstan-type Pair<TFirst, TSecond = bool> array{first: TFirst, second: TSecond}
 */

Each parameter may optionally carry an upper-bound constraint (of …) and/or a default type (= …), matching PHPStan's existing @template semantics.


Usage

Once defined, a generic alias is used the same way generic classes are — with angle-bracket arguments:

/**
 * @phpstan-type Paged<TItem of object> array{items: list<TItem>, total: int}
 */
class Repo
{
    /** @param Paged<User> $result */
    public function findUsers(array $result): void
    {
        // PHPStan knows $result['items'] is list<User>
        // and $result['total'] is int
    }
}

Type arguments are substituted into the alias body, so the full type flows through the analyser as if you had written it inline.


Imports

Generic aliases can be imported via @phpstan-import-type and then used with type arguments at the import site, just like locally-defined aliases:

/**
 * @phpstan-import-type Paged from Repo
 */
class Consumer
{
    /** @param Paged<Product> $page */
    public function handle(array $page): void { … }
}

Error detection

Three new categories of error are reported, all on the existing missing-typehint and incompatible-phpdoc rule levels:

1. Bare usage of a generic alias (missing required type arguments)

When a generic alias that has at least one required parameter (no default) is used without any type arguments, PHPStan reports a missing-typehint error — analogous to using array instead of array<int, string>.

/** @param Filter $x */   // ❌ Filter requires 1 type argument
public function bad(array $x): void {}

/** @param Filter<string> $x */   // ✅
public function good(array $x): void {}

Aliases whose every parameter has a default may be used bare without error:

/**
 * @phpstan-type WithDefault<T = string> array{value: T}
 */

/** @param WithDefault $x */  // ✅ T defaults to string
public function implicit(array $x): void {}

2. Too many type arguments

/**
 * @phpstan-type Single<T> array{value: T}
 */

/** @param Single<int, string> $x */  // ❌ Single takes 1 argument, 2 given
public function bad(array $x): void {}

3. Too few required type arguments

/**
 * @phpstan-type KeyVal<TKey of array-key, TValue> array{key: TKey, value: TValue}
 */

/** @param KeyVal<string> $x */  // ❌ Missing required argument TValue
public function bad(array $x): void {}

Implementation overview

TypeAliasTag — template parameter storage

TypeAliasTag now stores an array of TemplateTagValueNode[] alongside the existing TypeNode and NameScope. PhpDocNodeResolver::resolveTypeAliasTags forwards the templateTypes field from the phpdoc-parser AST node.

TypeAlias — resolution engine

TypeAlias gains three resolution strategies on top of the existing resolve():

Method When used
resolve() Returns the alias body with TemplateType placeholders intact (used internally as the base for both strategies below)
resolveWithArgs(TypeNodeResolver, Type[]) Substitutes concrete type arguments — called by resolveGenericTypeNode when the alias is written as Alias<T1, T2>
resolveWithDefaults(TypeNodeResolver) Substitutes only parameters that carry a default; required parameters remain as TemplateType placeholders — called when the alias is written bare (Alias) so that error detection can find the unresolved placeholders

Template type objects are created with a dedicated TemplateTypeScope::createWithTypeAlias(className, aliasName), giving them a distinct function name (__typeAlias_<aliasName>) so that MissingTypehintCheck can identify them as belonging to an alias rather than a class or function template.

TypeNodeResolver — dispatch

resolveGenericTypeNode (Alias<T1, T2> syntax): before falling through to the standard class-based generic resolution, it calls the new findGenericTypeAlias() helper. If the alias is found it validates the argument count and calls resolveWithArgs. Wrong counts produce ErrorType (which is what causes the incompatible-phpdoc error from IncompatiblePhpDocTypeRule).

resolveIdentifierTypeNode (bare Alias syntax): intercepts before delegating to UsefulTypeAliasResolver — if the identifier names a generic alias, it calls resolveWithDefaults so that required-parameter placeholders survive into the type system for MissingTypehintCheck to detect.

MissingTypehintCheck::getRawGenericTypeAliasesUsage()

New traversal method. Walks a Type tree looking for TemplateType instances whose TemplateTypeScope was created for a type alias (identified by the __typeAlias_ prefix). Any such placeholder that has no default is evidence of a bare required parameter → reported as a missing-typehint.

Rule-level integration

getRawGenericTypeAliasesUsage is wired into all the places that already call getMissingTypes or checkMissingTypehints:

  • MissingMethodParameterTypehintRule
  • MissingMethodReturnTypehintRule
  • MissingFunctionParameterTypehintRule / MissingFunctionReturnTypehintRule
  • MissingPropertyTypehintRule
  • MissingClassConstantTypehintRule
  • MissingMethodSelfOutTypeRule
  • LocalTypeAliasesCheck (with a self-skip guard so an alias's own body params are not reported)
  • AssertRuleHelper, InvalidPhpDocVarTagTypeRule, SetPropertyHookParameterRule, PropertyTagCheck, MethodTagCheck, MixinCheck

Bug fix: NameScope-poisoning regression

Adding the findGenericTypeAlias call in resolveIdentifierTypeNode exposed a subtle re-entrancy bug. When FileTypeMapper::getNameScope is building a class's NameScope (specifically, resolving the bound of a @phpstan-template tag), findGenericTypeAlias was calling ClassReflection::getTypeAliases(). This in turn called ClassReflection::getResolvedPhpDoc()fileTypeMapper->getResolvedPhpDoc()getNameScope() for the same class — which was still in $inProcess. The NameScopeAlreadyBeingCreatedException was caught, an empty ResolvedPhpDocBlock was cached, and ClassReflection::$typeAliases was permanently set to [], wiping out all the class's type aliases.

The fix mirrors the guard already present in UsefulTypeAliasResolver::resolveLocalTypeAlias: call $nameScope->hasTypeAlias($name) first; if the name is not registered as a type alias in the current scope, return null immediately — no reflection lookup needed. This prevents findGenericTypeAlias from ever triggering the re-entrant path for identifiers that aren't type aliases (e.g. template bounds like GotoTarget).


Tests

Test file What it covers
tests/PHPStan/Analyser/nsrt/generic-type-aliases.php Type inference: assertType checks for single-param, multi-param, defaulted, and imported generic aliases
tests/PHPStan/Rules/Methods/data/generic-type-alias-missing-typehint.php Missing-typehint detection: bare usage, all-defaulted (no error), partial defaults, imported alias
tests/PHPStan/Rules/PhpDoc/data/generic-type-alias-wrong-arg-count.php Wrong-arg-count detection: too many, too few required args
tests/PHPStan/Rules/PhpDoc/data/bug-11033.php Regression: GotoRouteDataTypes[value-of<T>] on a class that has both @phpstan-template and @phpstan-type aliases

For people familiar with generics from TypeScript , here's what generic-type-alias.php would look like:
https://jsfiddle.net/qky3r70m/

@phpstan-bot
Copy link
Copy Markdown
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.

@shmax shmax changed the title Generics on phpstan types Generics on phpstan-type aliases Apr 1, 2026
@shmax shmax force-pushed the generics-on-phpstan-types branch 2 times, most recently from 3dad6af to 8fca348 Compare April 2, 2026 04:25
@shmax shmax force-pushed the generics-on-phpstan-types branch from 8fca348 to 7f5b84d Compare April 2, 2026 04:39
@shmax shmax changed the base branch from 2.2.x to 2.1.x April 2, 2026 04:39
@shmax
Copy link
Copy Markdown
Author

shmax commented Apr 2, 2026

@ondrejmirtes I know you have a lot going on, but I spent the last few weeks going around in circles trying to plug generics more deeply into my codebase, before finally realizing that the problem was that they weren't really fully-implemented. This PR should get you most of the way there.

Wanted to note that a change like this will require a few small changes to the phpdoc parser; I didn't want to start on that PR without getting some buy-in on this one first, so for now I accomplished it with patch files.

Anyway, please let me know what you think, and if I should proceed with the parser changes.

@ondrejmirtes
Copy link
Copy Markdown
Member

I wouldn't hijack template types for this. First I'd try late-resolvable types. Look at LateResolvableType and its implementations. They all share LateResolvableTypeTrait so the actual classes are pretty small.

@shmax
Copy link
Copy Markdown
Author

shmax commented Apr 2, 2026

I wouldn't hijack template types for this. First I'd try late-resolvable types. Look at LateResolvableType and its implementations. They all share LateResolvableTypeTrait so the actual classes are pretty small.

Updated.

Here's the draft for the necessary changes to the phpdoc parser: phpstan/phpdoc-parser#297

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