Union types in Python

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'>
Ce contenu a été publié dans Non classé. Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *