Skip to content

← Precise Type Hints with Literals and Generics step 9 of 26

Medium Primitives

Describe Nested JSON with a Recursive Type Alias

Your service logs settings that arrive as decoded JSON. That means dictionaries, lists, strings, numbers, booleans and None, nested to any depth. For each setting you log how many levels it nests, and a flat view of its dictionary keys. First you need a type for "any JSON value".

assert solve({"a": {"b": 1}, "c": 2}) == (2, {"a.b": 1, "c": 2})
assert solve({"a": [1, {"b": 2}]}) == (3, {"a": [1, {"b": 2}]})

A type alias that refers to itself

A type alias is a name for a type. The type statement creates one:

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

Json appears on its own right-hand side. So a list[Json] can hold more lists and dictionaries, to any depth. You need no quotes around the inner Json, because Python does not evaluate the right-hand side until code reads it.

An alias is the same type under another name. Unlike NewType in the previous exercise, type Count = int makes Count and int interchangeable.

What to write

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]]: ...

Inputs are already decoded, contain no cycles, and use string keys. Do not change the input. solve returns (json_depth(value), flatten_keys(value, sep)).

Depth follows lists and dictionaries.

  • A scalar, including None, has depth 0.
  • A list or dictionary has depth 1 plus the largest depth of its children. The children of a dictionary are its values, not its keys.
  • An empty list or dictionary has depth 1.
assert json_depth(None) == 0
assert json_depth({}) == 1
assert json_depth([]) == 1
assert json_depth({"a": {"b": 1}}) == 2
assert json_depth({"a": [1, {"b": 2}]}) == 3

Flattening follows only nonempty dictionaries, in dictionary order.

  • If the root is not a dictionary, return {"": value}.
  • For each entry whose value is a nonempty dictionary, flatten that value, and put key + sep in front of each key it returns.
  • Every other value stays as it is, under its own key. Lists and empty dictionaries are leaves, even a list that holds dictionaries.
  • An empty root dictionary returns {}. A nested empty dictionary is a leaf and keeps its key.
assert flatten_keys({}) == {}
assert flatten_keys({"a": {}}) == {"a": {}}
assert flatten_keys({"a": [1, {"b": 2}]}) == {"a": [1, {"b": 2}]}
assert flatten_keys({"a": {"b": 1}}, "/") == {"a/b": 1}

Edge cases

  • sep can be any string, including "".
  • flatten_keys does not escape keys, so two paths can produce the same key, and the later value replaces the earlier one. {"a": {"b": 1}, "a.b": 2} flattens to {"a.b": 2}. With the two entries in the other order, it flattens to {"a.b": 1}.
  • You may return leaf lists and dictionaries as they are, without copying.

Narrow with the real classes

Narrow Json with isinstance(value, dict) and isinstance(value, list). isinstance(value, Json) raises TypeError, because an alias is not a class. Annotate the output dictionary as dict[str, Json]. You need no cast or # type: ignore.

Going further

  • The type statement is new in Python 3.12. It creates a TypeAliasType object, and Json.__value__ gives you the right-hand side.
  • Older code writes Json: TypeAlias = ..., with the inner "Json" in quotes. Python 3.12 deprecated TypeAlias in favor of the type statement.
  • Python's type alias documentation