We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Type System as a Design Tool step 2 of 24
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…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.