When Type Annotations Lie: Recursive Aliases in sanic

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

sanic-org/sanic issue #3159 exposes a subtle edge case in how Python handles recursive type definitions. The fix is only 2 lines, but the pattern behind it applies across any codebase using strict type checking.

PR: https://github.com/sanic-org/sanic/pull/3173

Status: Submitted (awaiting review)

Type checkers like mypy evaluate type aliases by expanding them inline. When a type alias references itself — like JsonType = Mapping[str, 'JsonType'] — mypy enters an infinite recursion trying to resolve the full type tree. This isn’t a mypy bug; it’s a fundamental limitation of static analysis for self-referential types.

The Recursion Mechanism

Mypy’s type resolution works by substituting alias definitions into usage sites. For a non-recursive alias like StrMap = Mapping[str, int], substitution terminates in one pass. But a recursive alias triggers repeated substitution:

JsonType → Mapping[str, JsonType] → Mapping[str, Mapping[str, JsonType]] → ...

This expansion never terminates because the alias references itself at every level. The recursion depth depends on how many layers mypy attempts before raising an error.

Why This Pattern Appears in Practice

Recursive JSON types are common in web frameworks. Sanic handlers process request bodies, configuration payloads, and API responses where nested dicts are the norm. Developers naturally express this as:

# Before: recursive type alias that crashes mypy --strict
JsonType = str | int | float | bool | None | Mapping[str, 'JsonType'] | list[Any]

This alias accurately describes the data — it is, by nature, a recursive structure. But accuracy and type-checkability diverge at the recursion boundary.

# After: Any breaks the recursion
JsonType = str | int | float | bool | None | Mapping[str, Any] | list[Any]

The difference is minimal: 'JsonType' becomes Any in the Mapping value type. This loses the typing of nested values but preserves the structural shape of the outermost layer. For the 95% case — handlers that access one or two levels deep — this provides sufficient coverage without crashing the type checker.

Alternative Approaches

For teams that need deeper recursive type checking, three alternatives exist:

  1. TypedDict with optional fields — flatten the known structure instead of recursing
  2. Protocol-based structural typing — define an interface for the expected shape
  3. PEP 673 Self type (Python 3.11+) — when the recursion is class-based rather than union-based

Each trades precision for type-checkability. The Any-at-boundary approach is the pragmatic choice for generic JSON handling where the exact shape varies at runtime.

Key Takeaway

Type annotations are not just documentation, and they are not cost-free. When mypy can’t resolve a recursive alias, replacing it with Any at the boundary preserves structural intent while making the type checker terminate. This is a deliberate tradeoff: a small loss of precision on nested values for a working type check on the outer structure. Recognize this pattern whenever you define self-referential types in strict-mode Python.


Discovered while fixing sanic-org/sanic#3159. View the fix post for the specific diff.

  • ToolBrain — tool reviews, LLM comparisons, and AI workflow guides

Cross-links automatically generated from CodeIntel Log.