When Type Annotations Lie: Recursive Aliases in cookiecutter

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

cookiecutter/cookiecutter issue #2217 exposes a subtle edge case in how Python handles recursive type definitions at the boundary of a type alias. The fix is only 2 lines, but the pattern behind it applies across any project using mypy strict mode.

PR: https://github.com/cookiecutter/cookiecutter/pull/2231

Status: Submitted (awaiting review)

Type checkers like mypy evaluate type aliases recursively. When a type alias references itself — like JsonType = Mapping[str, 'JsonType'] — mypy enters an infinite recursion trying to resolve the full type tree. The checker expands each self-reference until it hits the configured recursion limit, then raises an error.

# 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]

Why This Happens

Python’s type system allows self-referencing aliases for expressiveness — JSON values can contain nested JSON values, AST nodes can contain child AST nodes, and tree structures can nest indefinitely. But mypy’s type checker must resolve these aliases to verify type correctness, and without a base case, the expansion never terminates.

The mypy documentation on recursive types explains the limitation: self-referencing type aliases are intentionally restricted to prevent unbounded expansion. The --max-type-expansion flag controls the limit, but raising it just delays the crash — it doesn’t fix the underlying issue.

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 Any type tells mypy “accept any value here” — which is semantically correct for JSON leaf values, since the type checker can’t practically verify arbitrary nesting depth.

How to Scan Your Project

Run mypy in strict mode and look for recursion limit errors:

mypy --strict src/ 2>&1 | grep -i 'recursion\|exceeds max\|infinite'

Common locations for self-referencing types:

  • JSON serializers: JsonType = str | int | float | bool | None | Mapping[str, 'JsonType']
  • AST nodes: Node = str | int | list['Node']
  • Tree structures: TreeNode = dict[str, 'TreeNode']
  • Configuration schemas: Config = dict[str, 'Config' | str | int]

The fix is always the same: replace the self-reference with Any at the recursive branch. This preserves type safety for first-level values while avoiding infinite expansion during type checking.

For cases where you need more type safety than Any provides, Python’s typing.Protocol can define recursive structural types without triggering mypy’s recursion limit:

from typing import Protocol, Mapping, Sequence, Union

class JsonValue(Protocol):
    def __iter__(self) -> 'JsonValue': ...

This approach is more verbose but gives you structural typing without the recursion penalty. It’s worth considering when the Any boundary would erase too much type information for your use case.


Discovered while fixing cookiecutter/cookiecutter#2217. View the fix post for the specific diff.