Fix printing of type arguments for specialized recursive type aliases - #11600
Fix printing of type arguments for specialized recursive type aliases#11600Henry Su (hsusul) wants to merge 2 commits into
Conversation
A specialized generic recursive type alias lost its type arguments when
its type was converted to text, so `reveal_type`, hover, and diagnostic
messages reported an incorrect type.
type Pair[T] = tuple[T, Pair[T] | None]
def func(x: Pair[str]):
reveal_type(x) # tuple[str, Pair | None]
reveal_type(x[1]) # Pair[T@Pair] | None
The evaluated types were correct (`x[1][0]` evaluates to `str`), but the
printed form leaked the alias's own unsolved type parameter, and error
messages such as `Argument of type "Pair[T@Pair]" cannot be assigned to
parameter "x" of type "Pair[int]"` were misleading.
The printer expands a recursive alias by printing the synthesized
TypeVar's `boundType`, which is the unspecialized alias definition. The
type arguments stored in the type's `typeAliasInfo` were never applied,
so the recursion guard later printed the alias with its declared type
parameters (or with no type arguments at all).
Apply the alias's type arguments to the bound type before expanding it,
and retain those arguments on the expanded type so recursive references
within it print in specialized form. Also allow the synthesized TypeVar
that represents a recursive alias to be printed using the alias name
plus its type arguments; unlike a normal TypeVar it has no user-visible
name of its own.
This affects only the text representation of types; no evaluated type,
assignability result, or diagnostic count changes. Non-generic recursive
aliases print exactly as before. There is no precision regression: every
affected type prints strictly more information than it did previously.
|
🔒 Automated review in progress — Rich Chiodo (@rchiodo) is auto-reviewing this PR. |
| ...boundTypeAliasInfo, | ||
| typeArgs: aliasInfo.typeArgs, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
Please track the corresponding change for Pylance's async/typeServer typePrinter.ts path when this is pulled into pyrx. Without mirroring these three changes, async printing can still emit unspecialized recursive aliases and diverge from the fixed sync behavior.
|
|
||
| # This should generate an error because Alias1[str] | None is not | ||
| # assignable to Alias1[int]. | ||
| func9(x[1]) |
There was a problem hiding this comment.
Info · Optional note
The test currently verifies only that two diagnostics are produced. Since corrected diagnostic type text is part of the intended behavior, add an assertion that the relevant message includes Alias1[str] to prevent a diagnostic-printing regression that reveal-type checks alone may not catch.
Rich Chiodo (rchiodo)
left a comment
There was a problem hiding this comment.
Approved via Review Center.
Stella Huang (StellaHuang95)
left a comment
There was a problem hiding this comment.
Approved via Review Center.
|
🔒 Automated review in progress — Stella Huang (@StellaHuang95) is auto-reviewing this PR. |
Rich Chiodo (rchiodo)
left a comment
There was a problem hiding this comment.
Approved via Review Center.
Rich Chiodo (rchiodo)
left a comment
There was a problem hiding this comment.
Approved via Review Center.
| // name, which may have a scope associated with it. The exception is | ||
| // the synthesized TypeVar that represents a recursive type alias; | ||
| // it has no user-visible name of its own. | ||
| if (type.category !== TypeCategory.TypeVar || type.shared.recursiveAlias) { |
There was a problem hiding this comment.
Issue · Please address or respond
📍 packages/pyright-internal/src/analyzer/typePrinter.ts:329
The Skeptic verified that the diagnostic for func9(x[1]) still contains Alias1[T@Alias1] | None. Preserve the concrete specialization on the non-ExpandTypeAlias path so internal alias type parameters cannot leak into user-visible diagnostics.
[verified]
| test('RecursiveTypeAlias18', () => { | ||
| const analysisResults = TestUtils.typeAnalyzeSampleFiles(['recursiveTypeAlias18.py']); | ||
|
|
||
| TestUtils.validateResults(analysisResults, 2); |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
📍 packages/pyright-internal/src/tests/typeEvaluator3.test.ts:880
validateResults(..., 2) checks only the diagnostic count and therefore misses the verified T@Alias1 regression. Assert both complete diagnostic messages, following the exact-message pattern in typeEvaluator4.test.ts.
[verified]
| const transformedType = transformPossibleRecursiveTypeAlias( | ||
| applySolvedTypeVars(unspecializedType, solution), | ||
| recursionCount | ||
| ); |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
Retaining aliasInfo here affects every caller of transformPossibleRecursiveTypeAlias, not only printing. Add focused assert_type or alias assignability/equality coverage to demonstrate that the transformed type remains semantically unchanged.
|
|
||
|
|
||
| def func6(x: Alias6[int, str]): | ||
| reveal_type(x, expected_text="tuple[int, str, Alias6[int, str] | None]") |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
Coverage includes TypeVarTuple but not recursive aliases using ParamSpec or defaulted type parameters. Add focused rendering cases, or document why these parameter forms cannot reach this path.
Rich Chiodo (rchiodo)
left a comment
There was a problem hiding this comment.
Approved via Review Center.
Heejae Chang (heejaechang)
left a comment
There was a problem hiding this comment.
Approved via Review Center.
Problem
A specialized generic recursive type alias loses its type arguments when its type is converted to text, so
reveal_type, hover, and diagnostic messages report an incorrect type.xtuple[str, Pair | None]tuple[str, Pair[str] | None]x[1]Pair[T@Pair] | NonePair[str] | NoneThe evaluated types are correct —
x[1][0]evaluates tostr, andassert_type(x[1], Pair[str] | None)passes — so this is purely a defect in the printed form. But the printed form is what users see, andT@Pairis an internal, unsolved type parameter that has no meaning at the use site. It also leaks into diagnostics:The message names the wrong type; after the fix it reads
Argument of type "Pair[str] | None" ..., which actually explains the error.This is not specific to PEP 695 syntax — the same happens with the older spelling:
Non-generic recursive aliases are unaffected (
type Alias = int | list[Alias]already prints asint | list[Alias]).Root cause
A recursive type alias is represented as a synthesized
TypeVarwhoseshared.boundTypeholds the unspecialized alias definition, with the specialization recorded separately inprops.typeAliasInfo.typeArgs.printTypeInternalexpands such an alias by printingshared.boundTypedirectly (typePrinter.ts, theTypeCategory.TypeVarcase). It never appliestypeAliasInfo.typeArgs, so:typeAliasInfohas notypeArgsat all;Pair[T@Pair]) or, on the path that returnsshared.recursiveAlias.nameearly, with no type arguments (Pair).Fix
packages/pyright-internal/src/analyzer/typePrinter.ts, three small changes on the same path:typeAliasInfo.typeArgstoboundTypevia the existingbuildSolution/applySolvedTypeVarshelpers, and retain those arguments on the expanded type'stypeAliasInfoso recursive references inside it print in specialized form. Unspecialized aliases take the previous code path unchanged.shared.recursiveAlias.namewhen the alias has notypeArgs; otherwise fall through to the existing alias-name-plus-arguments printing.type.category !== TypeCategory.TypeVarcheck exists so a normal TypeVar prints its scoped name instead of an alias name; a recursive-alias placeholder has no user-visible name of its own, so it is exempted.No evaluator, binder, or checker logic is touched. The added work runs only while printing a specialized recursive alias, not during type evaluation.
Behavior change
Only the text representation of types changes, always in the direction of more information:
x: Alias1[str]wheretype Alias1[T] = tuple[T, Alias1[T] | None]tuple[str, Alias1 | None]tuple[str, Alias1[str] | None]x[1]Alias1[T@Alias1] | NoneAlias1[str] | Nonetype Alias5[T] = list[Alias5[list[T]]] | T,x: Alias5[int]list[Alias5] | intlist[Alias5[list[int]]] | inttype Alias6[*Ts] = tuple[*Ts, Alias6[*Ts] | None],x: Alias6[int, str]tuple[int, str, Alias6 | None]tuple[int, str, Alias6[int, str] | None]x: Alias1(unparameterized)tuple[Unknown, Alias1 | None]tuple[Unknown, Alias1[Unknown] | None]type Alias8 = int | list[Alias8],x: Alias8int | list[Alias8]Per
.github/agents/pyright-test-policy.md: no existing test was modified, no diagnostic was removed or suppressed, and there is no precision regression — every affected type prints strictly more information than before.Tests
New sample
packages/pyright-internal/src/tests/samples/recursiveTypeAlias18.py, registered asRecursiveTypeAlias18intypeEvaluator3.test.ts. It covers:TypeAlias+TypeVarspellings;Alias4[T, S] = tuple[T, S, Alias4[S, T] | None]→Alias4[str, int]);Alias5[T] = list[Alias5[list[T]]] | T);TypeVarTuplealias;Unknownarguments);On clean
upstream/main@dde0aae1the sample produces 11Type text mismatchfailures plus the 2 intended errors (Expected 2 errors, got 13); with the fix it passes.Validation
Run in a clean worktree at
upstream/main@dde0aae1:npx jest typeEvaluator3 -t "RecursiveTypeAlias18"(before fix)Expected 2 errors, got 13, 11 type-text mismatchesnpx jest typeEvaluator3 -t "RecursiveTypeAlias"(after fix)npm run test:norebuild(afternpm run webpack:testserver)npm run check:prettierAll matched files use Prettier code style!npm run check:eslintnpm run check:syncpackNo issues foundnpm run typechecknpx tsc --noEmitinpackages/pyright-internalgit diff --checkNot run:
build/perfCompare.py, which requires an external Python corpus. The changed code is on the type-to-text path, which runs when producing hover text and diagnostic messages rather than during type evaluation, and the addedapplySolvedTypeVarscall is reached only while printing a specialized recursive alias. Full-suite wall time was unchanged within noise (≈127–131 s across runs).Compatibility
No public API, no configuration option, no diagnostic rule, no dependency, and no lockfile change. Diagnostic categories, counts, and source ranges are unchanged; only the type text embedded in some messages differs.