skip to content

How do you write a recursive type alias for a nested JSON value, and why does the direct form fail?

level: seniorimportance: should knowfreq 28%

answer

  1. The name does not exist yet
  2. The right-hand side runs immediately
  3. Quote it, or declare it lazily
  4. 3.12 statement evaluates on demand
  5. Narrowing pain at every use site

basics

~20 s

The direct form raises NameError, because an alias assignment is an ordinary expression evaluated immediately and the alias is not yet bound. Quote it as a string forward reference, or use the lazily-evaluated type statement from Python 3.12.

solid answer

~50 s

A JSON value is recursive: a scalar, or a list of JSON values, or a dict of them. Writing `Json = None | bool | int | float | str | list[Json] | dict[str, Json]` fails with `NameError` at import, because the right-hand side of a plain assignment is evaluated on the spot and `Json` does not exist yet. The classic fix is to quote the alias so its value is a string the checker resolves later; from **Python 3.12** the `type` statement declares the alias lazily and recursion works unquoted. Recursive alias support is a checker feature, not an interpreter one. The bigger cost is at the use site: every access needs `isinstance` narrowing through a seven-member union, so keep the recursive alias at the boundary and parse into concrete shapes as soon as you know what you have.

code

pycon · 4 lines
pycon
>>> Json = None | bool | int | float | str | list[Json]
Traceback (most recent call last):
  ...
NameError: name 'Json' is not defined

go deeper

for a junior

Know that a name cannot be used on the right-hand side of the statement that defines it, and that quoting a type as a string is the standard way to refer to something not yet defined.

for a middle

Explain why the assignment fails -- eager evaluation, plain name resolution -- and name both working spellings: the quoted forward reference, and the lazily-evaluated type statement from 3.12.

for a senior

Show what it costs downstream: narrowing at every access, the temptation of Any, and the discipline of parsing into concrete shapes at the boundary. Be able to untangle a circular import caused by a shared alias module.

for a principal

Own where the untyped world ends. Decide which payloads stay genuinely dynamic, where the parse boundary lives, and how much narrowing burden the team accepts before a schema-backed model earns its keep.

### Why the obvious spelling explodes Consider a video-metadata extractor that reads whatever the container format hands back: a nested blob of scalars, lists and dicts. The honest type is recursive: ```python Json = None | bool | int | float | str | list[Json] | dict[str, Json] ``` This raises `NameError: name 'Json' is not defined` at import time. An alias assignment is an ordinary statement: Python evaluates the whole right-hand side first, and by the time it reaches `list[Json]` the name is still unbound. Nothing about type hints changes that -- the failure is plain name resolution, not the type system. Note that the lazy annotations of **Python 3.14** (PEP 649) do not save you here. They defer the evaluation of *annotations* -- what follows a colon in a signature or class body. An alias assignment is not an annotation; its right-hand side still runs eagerly. ### The two spellings that work **Quote it.** Making the value a string turns it into a forward reference. The checker parses and resolves it once every name is known; at runtime the alias is simply a `str`, and nothing is evaluated at all: ```python Json: TypeAlias = "None | bool | int | float | str | list[Json] | dict[str, Json]" ``` This works on every version that has `typing.TypeAlias` (**3.10**+) and is what you will find in codebases that must support older interpreters. **Declare it with the `type` statement** (**Python 3.12**, PEP 695). The value of a `type` statement is evaluated lazily, so a recursive reference resolves on demand and no quoting is needed. That is the modern spelling for new code; the object it creates and its parameter syntax are a topic of their own. Either way, recursion is understood by the *checker*. The interpreter has no concept of a recursive type; it holds a string, or a lazy object it never has to look inside. ### Living with the alias A recursive union is honest, and it is unpleasant to consume -- which is the senior half of this question. Every read of a `Json` value faces a seven-member union, so the checker demands narrowing before you can index, iterate or do arithmetic: ```python def duration_of(meta: "dict[str, Json]") -> float: value = meta.get("duration_s") return float(value) if isinstance(value, (int, float)) else 0.0 ``` Written across an entire extractor, that narrowing dominates the code. Three practices keep it tolerable: * **Keep the recursive alias at the boundary.** Use it for the raw payload, then parse into concrete shapes -- a `TypedDict` for known keys, a dataclass for anything with behaviour -- as soon as you know which shape you have. Downstream code then works with something narrow and checkable. * **Alias the common sub-shapes.** `JsonObject = "dict[str, Json]"` and `JsonArray = "list[Json]"` read far better in signatures than repeating the union. * **Resist `dict[str, Any]`.** It is the tempting alternative and it silently switches checking off for the whole subtree; the recursive alias at least forces the narrowing to be visible. Two operational notes are worth having ready. **Circular imports.** Shared aliases attract them: the extractor imports the alias module, the alias module imports a model for one of its members, and that model imports the extractor -- a circular import at startup that only shows up on the real entry point, not in the unit that first imported it. Keep the aliases in a leaf module with no runtime dependencies, and where an alias genuinely needs a heavy import, guard it under `typing.TYPE_CHECKING` and quote the alias so the name is never needed at runtime. **Cost.** None of this affects memory or speed. An extractor holding a 2.4 GB working set of parsed metadata pays exactly the same whether the payload is typed as a recursive alias, as `dict[str, Any]`, or not annotated at all -- the alias is a string or a lazy object, and the values are the same dicts and lists either way. Typing choices here buy checker coverage and reviewer clarity, never runtime behaviour, and a candidate who claims a performance effect has misunderstood what an alias is.

  • Do the lazily-evaluated annotations of Python 3.14 remove the need to quote a recursive alias?
    No. PEP 649 defers the evaluation of annotations -- what follows a colon in a signature or class body -- so a self-referencing annotation inside a class works without quotes. An alias assignment is not an annotation; its right-hand side is an ordinary expression that still runs at import, so the unquoted recursive form still raises `NameError`. The lazy alternative is the 3.12 `type` statement, whose value is evaluated on demand.
  • A shared alias module keeps causing a circular import at startup. How do you break it?
    Make the alias module a leaf: it should import nothing from the packages that import it. Where an alias genuinely references a heavy model, import that name under `typing.TYPE_CHECKING` and write the alias as a quoted string, so the name is needed only by the checker and never at runtime. If the cycle survives that, the alias is describing a shape that belongs to one side of the boundary, not to a shared module.
  • When would you stop at a recursive alias rather than model the payload precisely?
    When the shape genuinely is arbitrary -- a passthrough, a cache entry, a blob you re-emit unchanged -- the recursive alias is the truth and modelling it further is fiction. The moment code starts reaching into specific keys, that reaching is a claim about structure, and the claim belongs in a `TypedDict` or a dataclass produced by one parse at the boundary rather than in `isinstance` chains scattered across the module.

saying these in an interview costs you the question

  • Blames the type checker for the NameError
  • Thinks 3.14 lazy annotations fix an alias assignment
  • Says recursive types are impossible in Python
  • Claims the alias affects memory or parse speed
  • Reaches for dict[str, Any] and calls it typed
  • Believes quoting is only a style preference

context