Skip to content

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

Easy Framework

Flatten a union at runtime

Take a union apart at runtime, and survive both worlds while doing it.

Every junior reads Optional[int] as “this argument may be omitted” and then calls the function with no argument at all. It means nothing of the sort — it is exactly int | None, a value that may be None, with no bearing on whether the parameter has a default. PEP 604’s X | Y spelling has no such ambiguity, which is most of the reason Optional should die. (mypy’s no_implicit_optional, on by default since 0.990, already rejects the other half of the confusion: def f(x: int = None) is an error.)

That is the design argument. This problem is about the runtime one, because libraries that introspect annotations — pydantic, FastAPI, dataclass serialisers, DI containers, your own config loader — have to answer “is this type a union, and if so, of what?” and the answer changed underneath them.

The 3.14 change. types.UnionType is now an alias for typing.Union. On 3.12 and 3.13, int | str and Union[int, str] produced two different classes, and get_origin returned a different object for each. From 3.14 they are the same object, isinstance(obj, Union) works, and get_origin(t) is types.UnionType no longer distinguishes the two spellings. Code that branched on the difference is now dead — and code that only checked one of them was always half-broken.

The other trap: PEP 695 aliases are lazy. A type X = int | str alias does not eagerly expand. Put it inside another union and the alias survives as a member:

type Inner = int | str
get_args(Inner | bytes)     # (Inner, <class 'bytes'>) — not (int, str, bytes)

So the runtime’s own automatic flattening (int | (str | bytes) really is already flat, and Union[int, int] really does collapse on construction) stops at the alias boundary. Anything that walks annotations has to unwrap TypeAliasType.__value__ itself.

The task

def flatten_union(alias: str) -> tuple[str, ...]:

A module-level REGISTRY: Final[dict[str, object]] maps names to type objects. Look one up and return the names of its union members:

  • in declaration order,
  • fully flattened through nested unions and through PEP 695 aliases,
  • with duplicates removed (first occurrence wins),
  • as () — the empty tuple — if the entry is not a union at all.

Use typing.get_origin and typing.get_args. The registry entry for Optional[int] must give ("int", "NoneType"); the entry Inner | str where Inner = int | str must give ("int", "str"), because expanding the alias reveals the duplicate; and list[int] must give (), because a subscripted generic is not a union even though get_args happily returns something.

Returning a list where a tuple was asked for is a failure, not a near-miss.

Types

get_origin and get_args are typed -> Any in typeshed, which is the whole Any-laundering problem in one call. Do not let it out of your helpers: declare your recursive walker as taking and returning object, and convert to str through a narrowing isinstance check rather than by trusting __name__ to exist. If you find yourself reaching for cast, the shape of your helper is wrong.

Your submission must pass mypy --strict, which includes --warn-return-any — returning the result of get_args(...) directly will not pass.

Loading visualization…