Skip to main content Brad's PyNotes

PEP 604 Union Types

TL;DR

PEP 604 introduced the | operator for union types, allowing int | str instead of Union[int, str] for cleaner type annotations.

Interesting!

The | operator for union types makes Python’s type hints look more like mathematical set notation - int | str means “integer OR string”!

Before and After

python code snippet start

# Old verbose syntax
from typing import Union, Optional, List

def process_data(value: Union[int, str], 
                items: List[Union[int, float]], 
                config: Optional[dict]) -> Union[str, None]:
    pass

# New concise syntax (Python 3.10+)
def process_data(value: int | str,
                items: list[int | float],
                config: dict | None) -> str | None:
    pass

python code snippet end

Basic Union Types

python code snippet start

# Simple unions
def format_value(data: int | str | float) -> str:
    return str(data)

# Optional is just union with None
def get_user(user_id: int) -> dict | None:
    if user_id in users:
        return users[user_id]
    return None

# Multiple types in collections
def process_mixed_list(items: list[str | int | bool]) -> None:
    for item in items:
        if isinstance(item, str):
            print(f"String: {item}")
        elif isinstance(item, int):
            print(f"Number: {item}")
        elif isinstance(item, bool):
            print(f"Boolean: {item}")

python code snippet end

Runtime Type Checking

python code snippet start

# Union types work with isinstance()
number_or_string = int | str

def validate_input(value):
    if isinstance(value, number_or_string):
        print("Valid input!")
        return True
    else:
        print("Invalid input!")
        return False

validate_input(42)      # Valid input!
validate_input("hello") # Valid input!
validate_input([1, 2])  # Invalid input!

python code snippet end

Complex Nested Unions

python code snippet start

# Nested data structures with unions
from typing import TypeVar

T = TypeVar('T')

def process_response(data: dict[str, int | str | list[str]] | list[dict] | None) -> bool:
    if data is None:
        return False
    
    if isinstance(data, dict):
        # Process dictionary response
        return all(isinstance(v, (int, str, list)) for v in data.values())
    
    if isinstance(data, list):
        # Process list of dictionaries
        return all(isinstance(item, dict) for item in data)
    
    return False

python code snippet end

Function Overloading with Unions

python code snippet start

def serialize(obj: dict | list | str | int | float | bool | None) -> str:
    """Serialize various Python objects to JSON-compatible strings."""
    import json
    
    if obj is None:
        return "null"
    elif isinstance(obj, bool):
        return "true" if obj else "false"  
    elif isinstance(obj, (int, float)):
        return str(obj)
    elif isinstance(obj, str):
        return json.dumps(obj)  # Properly escape string
    elif isinstance(obj, (dict, list)):
        return json.dumps(obj)
    else:
        raise TypeError(f"Cannot serialize type {type(obj)}")

# Usage
print(serialize({"name": "Alice", "age": 30}))  # {"name": "Alice", "age": 30}
print(serialize([1, 2, 3]))                    # [1, 2, 3]
print(serialize("hello"))                      # "hello"
print(serialize(None))                         # null

python code snippet end

Backwards Compatibility

python code snippet start

# Code works with both syntaxes for gradual migration
from typing import Union

# Old style (still works)
def old_style(x: Union[int, str]) -> str:
    return str(x)

# New style (Python 3.10+)
def new_style(x: int | str) -> str:
    return str(x)

# Both are equivalent at runtime
assert old_style.__annotations__ == {'x': Union[int, str], 'return': str}
# new_style annotations are equivalent but use the | operator type

python code snippet end

Type Alias Definitions

python code snippet start

# Create reusable type aliases
NumberType = int | float | complex
StringOrBytes = str | bytes
JSONValue = dict | list | str | int | float | bool | None

def process_json(data: JSONValue) -> str:
    if isinstance(data, dict):
        return f"Object with {len(data)} keys"
    elif isinstance(data, list):
        return f"Array with {len(data)} items"
    elif isinstance(data, str):
        return f"String: {data}"
    elif isinstance(data, (int, float)):
        return f"Number: {data}"
    elif isinstance(data, bool):
        return f"Boolean: {data}"
    else:
        return "null"

# Use the alias
config: dict[str, NumberType | str] = {
    "timeout": 30,
    "host": "localhost",
    "port": 8080,
    "debug": True
}

python code snippet end

Practical API Design

python code snippet start

from pathlib import Path

def read_config(source: str | Path | dict) -> dict:
    """Read configuration from various sources."""
    if isinstance(source, str):
        # String path
        with open(source) as f:
            import json
            return json.load(f)
    elif isinstance(source, Path):
        # Path object
        return json.loads(source.read_text())
    elif isinstance(source, dict):
        # Already a dictionary
        return source.copy()
    else:
        raise TypeError("Config source must be string, Path, or dict")

# Flexible usage
config1 = read_config("config.json")
config2 = read_config(Path("config.json"))
config3 = read_config({"debug": True, "port": 8080})

python code snippet end

Union types with the | operator make type hints more readable and intuitive - bringing Python’s type system closer to how we naturally think about data!

Reference: PEP 604 – Allow writing union types as X | Y