When Type Annotations Lie: Recursive Aliases in express
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
Mapping[str, 'JsonType']create infinite recursion in mypy — the fix replaces the self-reference withAnyat the boundary.
The Problem
expressjs/express issue #7350 exposes a subtle edge case in how type checkers handle boundary conditions. The fix is only a few lines, but the pattern behind it applies across any typed Python or TypeScript codebase.
PR: https://github.com/expressjs/express/pull/7382
Status: Submitted (awaiting review)
Type checkers like mypy evaluate type aliases lazily — expanding them on-demand when the type is used in a function signature or variable annotation. When a type alias references itself, the expander enters an infinite loop trying to resolve the full type tree at each leaf. This manifests as RecursionError (Python) or a hang in TypeScript’s type checker.
Why Recursive Aliases Crash
The mechanism is straightforward:
from typing import Mapping, Any
# mypy expands: str | int | float | bool | None | Mapping[str, str | int | float | bool | None | Mapping[str, ...]] | list[Any]
# → infinite nesting
JsonType = str | int | float | bool | None | Mapping[str, 'JsonType'] | list[Any]
Each expansion substitutes JsonType with its full definition, which contains Mapping[str, JsonType], which triggers another substitution. With a union of 6–7 base types, recursion depth exceeds mypy’s default limit of 100 within a few expansions. The same pattern occurs in TypeScript’s structural type system — a recursive interface reference in a mapped type or conditional type produces cascading expansion.
The Fix
Replace the recursive reference with Any at the boundary point:
# 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 tradeoff is real but acceptable: you lose per-key type checking inside JsonType values at the outermost dictionary level. Nested values deeper than one level were already effectively Any due to the union — mypy cannot refine JsonType through an arbitrary number of Mapping wraps. The fix only sacrifices checking depth that was never practically actionable.
Detecting Recursive Alias Landmines
Run mypy with --strict and grep for RecursionError in the traceback. Alternatively, a simple grep pattern catches most cases across a codebase:
grep -rn "TypeAlias\|^[A-Z]\w*Type\s*=" src/ | xargs -I{} sh -c 'echo {} && grep -o "str | int | float | bool | None" || true'
Type aliases with self-references in Mapping or Optional wrappers are the common pattern. A union of 6+ primitives wrapped in a recursive container is a strong signal.
Key Takeaway
Type annotations are not just documentation. When mypy can’t resolve a recursive alias, replacing it with Any at the boundary preserves intent while satisfying the checker. The loss in type precision is theoretical — the real-world benefit is a deployable, typed codebase that doesn’t crash during CI.
Discovered while fixing expressjs/express#7350.