- PEP 604 (|) vs typing.Union: Deep Dive & Which One to Prefer
- Under the Hood: Why int | str is Not an Instance of type
- Dynamic Signatures: Accepting Types & Unions
- Runtime Introspection: get_origin & get_args
- Building a Polymorphic Coercion Engine
- Limitations, Subclass Pitfalls & PEP 563
PEP 604 (|) vs typing.Union: Deep Dive & Which One to Prefer
While int | str and Union[int, str] represent the same semantic concept to static type checkers, they behave differently at runtime.
| Feature | Modern Syntax (A | B – PEP 604) |
Legacy Syntax (typing.Union[A, B]) |
|---|---|---|
| Python Support | Python 3.10+ (Native runtime) | Python 3.5+ |
| Runtime Object | types.UnionType (Implemented in C) |
typing._UnionGenericAlias (Python-level) |
| Imports Required | None (Native language operator) | from typing import Union |
isinstance() Support |
Native support (e.g. isinstance(x, int | str)) |
❌ Raises TypeError |
| Runtime Performance | Faster instantiation (C-level dispatch) | Slightly slower (Python metaprogramming) |
– The fatal isinstance() difference:
One of the biggest practical differences is runtime validation. typing.Union crashes when passed to isinstance:
from typing import Union # 1. Modern PEP 604 pipe syntax works directly at runtime: print(isinstance(42, int | str)) # True # 2. Legacy typing.Union fails at runtime: try: print(isinstance(42, Union[int, str])) except TypeError as e: print(e) # TypeError: Subscripted generics cannot be used with class and instance checks |
– Which one should you prioritize?
– Always prioritize | (PEP 604) for any modern codebase targeting Python 3.10 and newer. It is cleaner, faster, requires zero imports, and seamlessly integrates with isinstance() and issubclass().
– Use typing.Union only for legacy compatibility when your code must execute at runtime on Python 3.9 or older.
– Note for Python 3.7 to 3.9: If you only need | inside type annotations (not as runtime variables), you can add from __future__ import annotations at the top of your file. However, expressions like param = int | str outside annotations will still raise a runtime TypeError on < 3.10.
Under the Hood: Why int | str is Not an Instance of type
– The type metadata discrepancy:
In Python, standard classes (like int, str, or custom Enum classes) are direct instances of the metaclass type.
However, type unions created via the binary pipe operator | (introduced in Python 3.10 via PEP 604) or legacy typing.Union do not inherit from type.
– Runtime type verification:
from types import UnionType from typing import Union from enum import Enum class CurrencyEnum(Enum): EUR = "EUR" USD = "USD" # Standard classes are instances of type print(isinstance(int, type)) # True print(isinstance(CurrencyEnum, type)) # True # Union objects are instances of UnionType or GenericAlias modern_union = int | CurrencyEnum legacy_union = Union[int, CurrencyEnum] print(type(modern_union)) # <class 'types.UnionType'> print(isinstance(modern_union, type)) # False print(isinstance(legacy_union, type)) # False |
Dynamic Signatures: Accepting Types & Unions
– Expanding parameter annotations:
To build a framework that dynamically accepts standard classes, modern unions, legacy unions, and generic aliases, parameter types must be broadened using types.UnionType.
– Robust parameter registration signature:
from types import UnionType from typing import Any, get_origin, get_args, Union def register_schema_field(field_name: str, target_type: type | UnionType) -> None: origin = get_origin(target_type) if origin is UnionType or origin is Union: sub_types = get_args(target_type) print(f"Registered composite field '{field_name}' with variants: {sub_types}") else: print(f"Registered atomic field '{field_name}' with type: {target_type}") |
Runtime Introspection: get_origin & get_args
– Core introspection utilities:
The standard library typing module provides two essential functions to inspect composite types at runtime:
– get_origin(tp): Returns types.UnionType (for |) or typing.Union, and returns None for standard classes.
– get_args(tp): Returns a tuple containing all allowed variant types inside the union.
– Runtime introspection and dynamic union assembly:
import functools import operator from types import UnionType from typing import get_origin, get_args, Union # 1. Inspecting Union structures target = int | str | bool print(get_origin(target)) # <class 'types.UnionType'> print(get_args(target)) # (<class 'int'>, <class 'str'>, <class 'bool'>) # 2. Dynamic runtime Union assembly from arbitrary lists allowed_types = [int, float, str] dynamic_union = functools.reduce(operator.or_, allowed_types) print(dynamic_union) # int | float | str # 3. Direct runtime type checking via isinstance (Python 3.10+) print(isinstance(42, int | str)) # True print(isinstance(3.14, int | str)) # False |
Building a Polymorphic Coercion Engine
– End-to-end framework implementation:
Below is a complete engine pattern capable of recursively coercing raw untyped payload data into appropriate atomic types, Enums, or Union variants.
from enum import Enum from types import UnionType from typing import Any, get_args, get_origin, Union class CurrencyEnum(Enum): EUR = "EUR" USD = "USD" GBP = "GBP" def coerce_payload_value(raw_val: Any, target_spec: Any) -> Any: origin = get_origin(target_spec) # 1. Resolve Union variants recursively (handles both | and typing.Union) if origin is UnionType or origin is Union: allowed_variants = get_args(target_spec) for variant in allowed_variants: try: return coerce_payload_value(raw_val, variant) except (ValueError, TypeError): continue raise ValueError( f"Value '{raw_val}' could not be coerced into any valid variant: {allowed_variants}" ) # 2. Resolve Enums if isinstance(target_spec, type) and issubclass(target_spec, Enum): if isinstance(raw_val, target_spec): return raw_val return target_spec(str(raw_val)) # 3. Resolve Atomic Primitive Types (int, str, float, etc.) if isinstance(target_spec, type): if isinstance(raw_val, target_spec): return raw_val return target_spec(raw_val) raise TypeError(f"Unsupported type specification: {target_spec}") # Execution Examples amount_type = int | CurrencyEnum print(coerce_payload_value("500", amount_type)) # 500 (coerced to int) print(coerce_payload_value("EUR", amount_type)) # CurrencyEnum.EUR (coerced to Enum) |
Limitations, Subclass Pitfalls & PEP 563
– 1. Non-Instantiability:
Unions are type metadata descriptions, not constructors. Calling (int | str)("123") raises a runtime TypeError: cannot create 'types.UnionType' instances.
– 2. Automatic Flattening & Deduplication:
The Python interpreter flattens nested unions and deduplicates redundant types automatically:
# Nested expressions flatten immediately flat_type = (int | str) | (float | int) print(flat_type) # int | str | float |
– 3. The bool Subclass Trap (int | bool):
In Python, bool is a direct subclass of int (issubclass(bool, int) == True). If an engine iterates sequentially over get_args(int | bool) using isinstance(val, int), a boolean value like True will match the int branch and become integer 1. Exact type matching (type(val) is bool) must precede subclass checks.
– 4. Deferred Stringified Annotations (PEP 563):
When from __future__ import annotations is active, type annotations are stored internally as raw strings ("int | CurrencyEnum") rather than type objects. Frameworks must resolve them using typing.get_type_hints():
from __future__ import annotations import typing class TransferRequest: amount: int | CurrencyEnum # Target annotations are stored as strings: {'amount': 'int | CurrencyEnum'} # Resolving strings into runtime UnionType instances: resolved_hints = typing.get_type_hints(TransferRequest) print(type(resolved_hints["amount"])) # <class 'types.UnionType'> |