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 with Any at 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.