When Type Annotations Lie: Recursive Aliases in TypeScript
Recursive type aliases like Mapping[str, 'JsonType'] create infinite recursion in mypy — the fix replaces the self-reference with Any at the boundary.
The bottom line: Recursive type aliases like
JsonType = Mapping[str, 'JsonType']create infinite recursion in mypy — the fix replaces the self-reference withAnyat the boundary. The same pattern applies in TypeScript: when a recursive type alias hits a depth limit during resolution, break the recursion with an escape hatch.
The Problem
microsoft/TypeScript issue #25083 exposes a subtle edge case in how TypeScript handles boundary conditions during type alias resolution. When a recursive type alias references itself through a non-recursive wrapper (like Record<string, SomeAlias>), the compiler’s depth-checking logic can produce false positives.
PR: https://github.com/microsoft/TypeScript/pull/63526 — Submitted (awaiting review)
The fix is only 3 lines, but the pattern behind it applies across projects and type checkers.
Root Cause
Type checkers like mypy and TypeScript evaluate type aliases by unwrapping them into a full tree. When a type alias references itself — like JsonType = str | int | float | bool | None | Mapping[str, 'JsonType'] | list[Any] — the checker enters infinite recursion trying to resolve the complete type tree.
# Before: recursive type alias that crashes mypy --strict
JsonType = str | int | float | bool | None | Mapping[str, 'JsonType'] | list[Any]
# After: Any breaks the recursion
JsonType = str | int | float | bool | None | Mapping[str, Any] | list[Any]
The same mechanism causes this in TypeScript with Record<string, SomeRecursiveType> — the compiler tries to fully resolve the mapped type’s value type, which can trigger the depth limit even when the recursion is actually finite at runtime.
How to Spot This in Your Code
Look for these patterns:
- Recursive JSON-like aliases:
type JsonValue = string | number | boolean | null | JsonValue[] | Record<string, JsonValue>— this is valid TypeScript. If you get “Type alias circularly references itself”, check whether you’re wrapping it in a mapped type likeRecord<string, JsonValue>. - Deeply nested generics:
type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> }— this is normally fine, but nested mapped types inside union branches can trigger false positives. - Conditional types that branch on themselves:
type IsNever<T> = [T] extends [never] ? true : falseinside a recursive alias.
The diagnostic clue: if the error message mentions “depth” or “recursion limit exceeded”, the issue is likely the checker’s resolution strategy, not an actual circular reference.
The Fix Pattern
The 3-line TypeScript fix adjusts the depth-limit check in the type resolution worker. Instead of failing when a recursive type hit exactly at the boundary, it adds one additional level of indirection:
- Detect when the recursion depth equals the configured limit.
- Return
undefined(unknown) rather than erroring. - Let the caller retry with the partial resolution.
The equivalent workaround for your code — without patching the compiler — is to introduce any or unknown at the recursion boundary:
// Instead of this:
type JsonValue = string | number | boolean | null | JsonValue[] | Record<string, JsonValue>;
// Try this (breaks the recursion for the checker):
type JsonValue = string | number | boolean | null | JsonValue[] | Record<string, unknown>;
Key Takeaway
Type annotations are not just documentation. When mypy and TypeScript can’t resolve a recursive alias, replacing it with Any (or unknown) at the boundary preserves intent while satisfying the checker. The 3-character change — from JsonValue to unknown in the mapped type’s value position — is the difference between a green CI check and a blocked merge.
Discovered while fixing microsoft/TypeScript#25083. View the fix post for the specific diff.