Skip to content

← The Type System as a Design Tool step 9 of 24

Medium Primitives

A recursive Json alias, depth and flatten

Before Python 3.12, writing down the type of “some JSON” was genuinely painful. You needed a quoted forward reference, and mypy’s support for the resulting recursive alias was young and full of edges. Most codebases gave up and wrote dict[str, Any], which is how a “fully typed” config loader ends up checking nothing.

PEP 695’s type statement makes it a one-liner:

type Json = str | int | float | bool | None | list[Json] | dict[str, Json]

No quotes. The right-hand side is lazily evaluated — it is not executed until something asks for Json.__value__ — so it may refer to itself, and to names defined later in the module. The statement creates a TypeAliasType object, which is generic-capable (type Pair[T] = tuple[T, T]) and recursive.

Three things worth pinning down:

  • X: TypeAlias = ... is deprecated as of 3.12. It still works; it is the transitional spelling from PEP 613.
  • A bare assignment is ambiguous. Json = str | int might be a type alias or might be a variable holding a type object, and mypy’s guess produces the famously unhelpful “Variable … is not valid as a type”.
  • type UserId = int is not a distinct type. It is a second name for int, and UserId and int are interchangeable in both directions. If you wanted distinctness, that is NewType‘s job. Also: TypeAliasType is not a class, so isinstance(x, Json) is a TypeError.

The task

type Json = str | int | float | bool | None | list[Json] | dict[str, Json]

def json_depth(value: Json) -> int: ...
def flatten_keys(value: Json, sep: str = ".") -> dict[str, Json]: ...
def solve(value: Json, sep: str = ".") -> tuple[int, dict[str, Json]]: ...

json_depth — nesting depth. A scalar (including None) is 0. Any list or dict is 1 + the maximum depth of its children, and 1 when it has none. So {} and [] are both 1, and {"a": {"b": 1}} is 2.

flatten_keys — dotted-path flattening, dicts only:

  • If value is not a dict, return {"": value}.
  • Otherwise, for each key: if the child is a non-empty dict, recurse and prefix each of its paths with key + sep; otherwise the child is a leaf and is emitted under key unchanged.
  • Lists are leaves. {"a": [1, {"b": 2}]} flattens to {"a": [1, {"b": 2}]} — the list is not descended into, even though it contains a dict.
  • An empty dict is a leaf too, so {"a": {}} flattens to {"a": {}} rather than vanishing.

solve returns (json_depth(value), flatten_keys(value, sep)).

Types

The recursive alias is doing real work. isinstance(value, dict) narrows Json to dict[str, Json], so value.items() gives you str keys and Json values with no further help; isinstance(value, list) narrows to list[Json]. Every branch of both functions stays fully typed all the way down.

No Any, no cast, no # type: ignore. If you find yourself wanting one, you have probably annotated the accumulator as a plain dict — which --disallow-any-generics rejects anyway — instead of dict[str, Json].

Note what you cannot do: isinstance(value, Json) does not work, because Json is a TypeAliasType and not a class. Narrowing goes through the concrete runtime types the alias is built from.

Watch the depth definition

Depth counts containers, not keys. {"a": null, "b": {"c": 1, "d": "s"}} is depth 2, not 3: the outer dict is one level, {"c": ..., "d": ...} is the second, and the scalars inside contribute nothing. Getting this off by one is the most common failure on this problem.

Loading visualization…