Skip to content

Fix printing of type arguments for specialized recursive type aliases - #11600

Open
Henry Su (hsusul) wants to merge 2 commits into
microsoft:mainfrom
hsusul:fix/recursive-type-alias-type-args-printing
Open

Fix printing of type arguments for specialized recursive type aliases#11600
Henry Su (hsusul) wants to merge 2 commits into
microsoft:mainfrom
hsusul:fix/recursive-type-alias-type-args-printing

Conversation

@hsusul

Copy link
Copy Markdown
Contributor

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.

type Pair[T] = tuple[T, Pair[T] | None]


def func1(x: Pair[str]):
    reveal_type(x)  # tuple[str, Pair | None]
    reveal_type(x[1])  # Pair[T@Pair] | None
Expression Current Expected
x tuple[str, Pair | None] tuple[str, Pair[str] | None]
x[1] Pair[T@Pair] | None Pair[str] | None

The evaluated types are correct — x[1][0] evaluates to str, and assert_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, and T@Pair is an internal, unsolved type parameter that has no meaning at the use site. It also leaks into diagnostics:

def func2(x: Pair[int]): ...


def func3(x: Pair[str]):
    func2(x[1])
error: Argument of type "Pair | None" cannot be assigned to parameter "x" of type "Pair[int]"

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:

S = TypeVar("S")
Pair2: TypeAlias = "tuple[S, Pair2[S] | None]"


def func4(x: Pair2[int]):
    reveal_type(x[1])  # Pair2[S@Pair2] | None

Non-generic recursive aliases are unaffected (type Alias = int | list[Alias] already prints as int | list[Alias]).

Root cause

A recursive type alias is represented as a synthesized TypeVar whose shared.boundType holds the unspecialized alias definition, with the specialization recorded separately in props.typeAliasInfo.typeArgs.

printTypeInternal expands such an alias by printing shared.boundType directly (typePrinter.ts, the TypeCategory.TypeVar case). It never applies typeAliasInfo.typeArgs, so:

  • the expanded body carries the alias's own type parameters, and its typeAliasInfo has no typeArgs at all;
  • when the recursion guard is then hit, the alias is printed from that body — with its declared type parameters (Pair[T@Pair]) or, on the path that returns shared.recursiveAlias.name early, with no type arguments (Pair).

Fix

packages/pyright-internal/src/analyzer/typePrinter.ts, three small changes on the same path:

  1. When expanding a recursive alias, apply typeAliasInfo.typeArgs to boundType via the existing buildSolution / applySolvedTypeVars helpers, and retain those arguments on the expanded type's typeAliasInfo so recursive references inside it print in specialized form. Unspecialized aliases take the previous code path unchanged.
  2. In the recursion guard, only return the bare shared.recursiveAlias.name when the alias has no typeArgs; otherwise fall through to the existing alias-name-plus-arguments printing.
  3. Let the synthesized TypeVar that represents a recursive alias return the alias name from that printing block. The surrounding type.category !== TypeCategory.TypeVar check 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:

Expression Before After
x: Alias1[str] where type Alias1[T] = tuple[T, Alias1[T] | None] tuple[str, Alias1 | None] tuple[str, Alias1[str] | None]
x[1] Alias1[T@Alias1] | None Alias1[str] | None
type Alias5[T] = list[Alias5[list[T]]] | T, x: Alias5[int] list[Alias5] | int list[Alias5[list[int]]] | int
type 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: Alias8 int | list[Alias8] unchanged

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 as RecursiveTypeAlias18 in typeEvaluator3.test.ts. It covers:

  • the original failing case, in both PEP 695 and TypeAlias + TypeVar spellings;
  • a two-type-parameter alias whose recursive reference reorders them (Alias4[T, S] = tuple[T, S, Alias4[S, T] | None]Alias4[str, int]);
  • a recursive reference specialized with a type derived from the type parameter (Alias5[T] = list[Alias5[list[T]]] | T);
  • a TypeVarTuple alias;
  • an unparameterized reference to a generic recursive alias (implicit Unknown arguments);
  • a non-generic recursive alias, which must keep printing without type arguments — this one passes before and after;
  • two intentional errors, asserting the specialization now appears in diagnostic messages.

On clean upstream/main @ dde0aae1 the sample produces 11 Type text mismatch failures 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:

Command Result
npx jest typeEvaluator3 -t "RecursiveTypeAlias18" (before fix) fails: Expected 2 errors, got 13, 11 type-text mismatches
npx jest typeEvaluator3 -t "RecursiveTypeAlias" (after fix) 18 passed
npm run test:norebuild (after npm run webpack:testserver) 62 suites, 2550 passed, 0 failed
npm run check:prettier All matched files use Prettier code style!
npm run check:eslint clean
npm run check:syncpack No issues found
npm run typecheck clean in all 3 packages
npx tsc --noEmit in packages/pyright-internal clean
git diff --check clean

Not 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 added applySolvedTypeVars call 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.

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.
@rchiodo

Rich Chiodo (rchiodo) commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

🔒 Automated review in progress — Rich Chiodo (@rchiodo) is auto-reviewing this PR.

...boundTypeAliasInfo,
typeArgs: aliasInfo.typeArgs,
});
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@rchiodo Rich Chiodo (rchiodo) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved via Review Center.

@rchiodo Rich Chiodo (rchiodo) added the review-auto:approved Automated review: no blocking findings (approval posted). label Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved via Review Center.

@StellaHuang95

Stella Huang (StellaHuang95) commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

🔒 Automated review in progress — Stella Huang (@StellaHuang95) is auto-reviewing this PR.

@rchiodo Rich Chiodo (rchiodo) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved via Review Center.

@rchiodo Rich Chiodo (rchiodo) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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]

@StellaHuang95 Stella Huang (StellaHuang95) added review-auto:changes-requested Automated review: posted blocking findings to address. and removed review-auto:approved Automated review: no blocking findings (approval posted). labels Aug 13, 2026
const transformedType = transformPossibleRecursiveTypeAlias(
applySolvedTypeVars(unspecializedType, solution),
recursionCount
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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]")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@rchiodo Rich Chiodo (rchiodo) added review-auto:approved Automated review: no blocking findings (approval posted). and removed review-auto:changes-requested Automated review: posted blocking findings to address. labels Aug 17, 2026

@rchiodo Rich Chiodo (rchiodo) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved via Review Center.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved via Review Center.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-auto:approved Automated review: no blocking findings (approval posted).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants