Skip to content

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

Easy Framework

List the Members of a Union at Runtime

Some tools read type hints while the program runs. A JSON serializer or a settings loader, for example, may need to know that a field typed int | str | None can hold an int, a str or None. Python gives you two functions for this:

from typing import get_args, get_origin

hint = int | str | None
get_args(hint)    # the members, (int, str, NoneType)
get_origin(hint)  # the union class, which says what kind of type this is

Two things make this harder than it looks:

  • get_args(list[int]) is (int,), but list[int] is not a union. Check get_origin first.
  • A type alias made with type, as in the recursive JSON exercise, stays an alias inside a union. Its __value__ is the type it names:
type Inner = int | str

get_args(Inner | bytes)    # (Inner, bytes)
get_args(Inner.__value__)  # (int, str)

Inner is a TypeAliasType object. To see its members, expand it first.

What to write

flatten_union(alias: str) -> tuple[str, ...] takes a key of the supplied REGISTRY and returns the names of the union's members:

  1. Expand the registry entry while it is a TypeAliasType. If the result is not a union, return ().
  2. Visit the members in the order get_args gives them. Expand each member that is an alias. If a member is itself a union, visit its members in turn.
  3. Turn each member into a name. Use its __name__ when that is a string, and str(member) otherwise.
  4. Drop repeated names, keeping the first.
  5. Return the names as a tuple.

A value is a union when get_origin(value) in UNION_ORIGINS. The supplied UNION_ORIGINS holds both union classes, typing.Union and types.UnionType.

Key Result
"pipe" or "union_form" ("int", "str")
"optional_int" ("int", "NoneType")
"alias_member" or "alias_itself" ("int", "str", "bytes")
"alias_overlap" ("int", "str")
"optional_alias" ("int", "str", "NoneType")
"plain_int" or "generic" ()

The registry holds no recursive aliases, so the expansion always ends.

Keep mypy checking your names

get_args returns tuple[Any, ...], and mypy does not check Any. So mypy would accept a tuple that holds classes instead of names. Annotate your helpers with object, for example expand(tp: object) -> object and members(tp: object) -> list[object]. Read a name with getattr(member, "__name__", None), and check isinstance(name, str) before you return it.

Going further

On Python 3.12 and 3.13, int | str and Union[int, str] have different origins, types.UnionType and typing.Union. Python 3.14 made types.UnionType another name for typing.Union, so both give the same origin. UNION_ORIGINS lists both, so the check works on every version. Removing repeated names is this exercise's rule, not a test of whether two types are equal. Two different classes with the same name would merge.