## Problem
ty hangs indefinitely when type-checking files with mutually recursive
`TypeAliasType` definitions created via the manual constructor:
```python
A = TypeAliasType('A', Union[str, 'B'])
B = TypeAliasType('B', list[A])
```
This was discovered while testing ty on pydantic's `test_type_alias_type.py`.
## Root Cause
`ManualPEP695TypeAliasType` was a `#[salsa::interned]` struct with the
resolved value type as one of its fields:
```rust
#[salsa::interned]
pub struct ManualPEP695TypeAliasType<'db> {
pub name: ast::name::Name,
pub definition: Option<Definition<'db>>,
pub value: Type<'db>, // <-- causes non-convergence
}
```
With `#[salsa::interned]`, ALL fields contribute to identity. When Salsa
detects a cycle in `infer_definition_types`, it re-executes the function
iteratively until the result converges (stops changing). But each iteration
produces a different `value` type as the cycle resolves, creating a new
interned ID each time:
- Iteration 1: value=`Union[str, Divergent]` → interned ID X1
- Iteration 2: value=`Union[str, TypeAlias_B]` → interned ID X2
- Iteration 3: yet another value → interned ID X3
- ... never converges
This contrasts with `PEP695TypeAliasType` (the `type X = ...` form), which
does NOT store the value in the interned struct and instead computes it
lazily via a separate tracked method with its own cycle detection.
## Approaches Explored
### Approach 1: `#[salsa::tracked]` with `#[no_eq]` on value
Changed from `#[salsa::interned]` to `#[salsa::tracked]` with `#[no_eq]`
on the value field, so that value changes don't affect the struct's identity.
**Result**: Still hangs. Tracked struct recycling does NOT work during Salsa
cycle re-iterations — each iteration creates a new tracked struct with a
fresh ID (observed IDs growing unboundedly: 9800, 9801, 9802, ...).
Additionally, `normalized_impl` and `recursive_type_normalized_impl` called
`Self::new()` which created additional tracked structs. Even after fixing
those to return `self`, the fundamental ID proliferation remained.
### Approach 2: Remove value from interned struct + register RHS as standalone expression
Removed the `value` field from the interned struct and computed it lazily
via a tracked `value_type()` method (mirroring PEP695TypeAliasType). But
the lazy method needs the `Definition` to find the AST node, and for simple
assignments (`X = expr`), the semantic index builder does NOT register the
RHS as a standalone expression — so `try_expression()` returns None and the
definition is unavailable.
Attempted to fix this by registering the RHS call expression as standalone
in the semantic index builder.
**Result**: Breaks TypeVar, ParamSpec, NamedTuple, and NewType handling.
`infer_assignment_definition_impl` has two code paths: if a standalone
expression exists, it uses generic `infer_standalone_expression_impl`; if
not, it checks for TypeVar/ParamSpec/NamedTuple/NewType/etc. and uses
specialized handlers. Registering call expressions as standalone causes ALL
of these to take the wrong path.
### Approach 3 (final): Remove value + thread definition via infer_assignment_definition_impl
Same lazy value computation as Approach 2, but instead of modifying the
semantic index, thread the definition through the existing type inference
path. In `infer_assignment_definition_impl`, `TypeAliasType` calls already
pass through the `else if let ast::Expr::Call` branch where the `definition`
is in scope. Added a `KnownClass::TypeAliasType` match arm that:
1. Does normal call inference (which creates a ManualPEP695TypeAliasType
with definition=None via check_call)
2. Patches up the result with the definition that's available in scope
This is the same pattern used for TypeVar, ParamSpec, NewType, and
builtins.type — all have special handling in this match.
## Changes
- **types.rs**: Removed `value` field from `ManualPEP695TypeAliasType`.
Added lazy `value_type()` tracked method with Salsa cycle annotations.
`normalized_impl` and `recursive_type_normalized_impl` return `self`
since the interned identity no longer depends on the value.
- **class.rs**: Updated `check_call` to not pass `value` to `new()`.
- **infer/builder.rs**: Added `KnownClass::TypeAliasType` match arm in
`infer_assignment_definition_impl` to supply the definition that
`check_call` can't find via `try_expression` for simple assignments.
- **arguments.rs**: Removed `expand_pep695_type_alias` unit test (can't
construct ManualPEP695TypeAliasType with a value in unit tests anymore;
functionality is covered by mdtests).
- **pep695_type_aliases.md**: Added mdtest for mutually recursive
TypeAliasType definitions.
- **cycle.md**: Updated test expectation for self-referential JSONValue
TypeAliasType — now correctly resolves to the actual type instead of
Divergent, since lazy evaluation means the alias is already bound by
the time value_type() runs.
Summary
Fix an infinite hang when type-checking files with mutually recursive
TypeAliasTypedefinitions created via the manual constructor:This was discovered while testing ty on pydantic's
tests/test_type_alias_type.py, which contains self-referentialTypeAliasTypedefinitions such as:Root Cause
ManualPEP695TypeAliasTypewas a#[salsa::interned]struct with the resolved value type as one of its fields:With
#[salsa::interned], all fields contribute to identity/deduplication. When Salsa detects a cycle ininfer_definition_types, it re-executes the function iteratively until the result converges (stops changing). But each iteration produces a differentvaluetype as the cycle resolves, creating a new interned ID each time:Union[str, Divergent](B not yet resolved) → interned ID X1Union[str, TypeAlias_B](B resolved) → interned ID X2This contrasts with
PEP695TypeAliasType(thetype X = ...form), which does NOT store the value in the interned struct and instead computes it lazily via a separate tracked method with its own cycle detection.Fix
Remove the
valuefield fromManualPEP695TypeAliasTypeand compute it lazily (matching thePEP695TypeAliasTypepattern):types.rs: Removedvaluefield. Added a lazyvalue_type()tracked method with Salsa cycle annotations that re-parses the value from the definition's AST on demand.infer/builder.rs: Created a dedicatedinfer_typealiastype_callmethod, following the same pattern used byTypeVar,ParamSpec, andNewType. This validates the arguments, constructs theManualPEP695TypeAliasType, and defers inference of the value argument to avoid cycles. Also added an "invalid context" diagnostic whenTypeAliasTypeis used outside a simple variable assignment.class.rs: Removed theTypeAliasTypehandling fromKnownClass::check_callentirely, since it is now fully handled by the dedicated inference method.Additional improvements:
TypeVarandNewType).typing.TypeAlias, now saysTypeAliasType).JSONValuetest case incycle.mdnow correctly resolves to the actual type instead ofDivergent, since lazy evaluation means the alias is already bound by the timevalue_type()runs.TypeAliasTypevariables now correctly navigates to the assignment (previously returned "No type definitions found" because the definition wasNone).Approaches Explored (and rejected)
#[salsa::tracked]with#[no_eq]on valueChanged from
#[salsa::interned]to#[salsa::tracked]with#[no_eq]on the value field. Result: Still hangs — tracked struct recycling does NOT work during Salsa cycle re-iterations. Each iteration creates a new tracked struct with a fresh ID (observed IDs growing unboundedly: 9800, 9801, 9802, ...).Register RHS as standalone expression in the semantic index builder
Registered the call expression RHS as a standalone expression so that
check_callcould find the definition viatry_expression. Result: BreaksTypeVar,ParamSpec,NamedTuple, andNewTypehandling, sinceinfer_assignment_definition_implhas a code path for standalone expressions (generic inference) and a separate code path with specialized handlers for these known classes. Registering call expressions as standalone causes all of them to take the wrong path.Test plan
TypeAliasTypedefinitions inpep695_type_aliases.mdcycle.mdtest expectation (improved fromDivergentto actual resolved type)ty_idesnapshot forgoto_type_of_bare_type_alias_type(now correctly finds the definition)