APIs often return different types depending on the situation - a lookup might return a user object or an error code. Union types let you express "this value can be one of several types" in a type-safe way, and type checkers help you handle each case correctly.

Unions in Collections

union_basics.py
# Union basics

from typing import Union

# Two equivalent forms
Value1 = Union[int, str]
Value2 = int | str

v1: Value1 = 
v2: Value2 = "ten"

print("v1:", v1)
print("v2:", v2)

# Function parameter union

def stringify(x: int | str) -> str:
    return str(x)

print("stringify(123) =", stringify(123))
print("stringify('abc') =", stringify("abc"))

# Union basics

from typing import Union

# Two equivalent forms
Value1 = Union[int, str]
Value2 = int | str

v1: Value1 = 
v2: Value2 = "ten"

print("v1:", v1)
print("v2:", v2)

# Function parameter union

def stringify(x: int | str) -> str:
    return str(x)

print("stringify(123) =", stringify(123))
print("stringify('abc') =", stringify("abc"))

# Union basics

from typing import Union

# Two equivalent forms
Value1 = Union[int, str]
Value2 = int | str

v1: Value1 = 
v2: Value2 = "ten"

print("v1:", v1)
print("v2:", v2)

# Function parameter union

def stringify(x: int | str) -> str:
    return str(x)

print("stringify(123) =", stringify(123))
print("stringify('abc') =", stringify("abc"))

isinstance_narrow.py
# Narrow a union with isinstance


def double(x: int | str) -> int | str:
    # Narrow to int
    if isinstance(x, int):
        return x * 2

    # Narrow to str
    return x + x

print("double(10) =", double(10))
print("double('hi') =", double("hi"))

match_case.py
# match/case with unions


def describe(x: int | str | None) -> str:
    match x:
        case None:
            return "none"
        case int() as n:
            return f"int:{n}"
        case str() as s:
            return f"str:{s}"

    # unreachable
    return "unknown"

print(describe(None))
print(describe(7))
print(describe("ok"))

tagged_dict.py
# Tagged dict payloads

from typing import Any

# A simple tagged payload pattern
# In real code you might use TypedDict, dataclasses, or pydantic.


def handle_event(evt: dict[str, Any]) -> str:
    kind = evt.get("type")

    if kind == "login":
        user = evt.get("user")
        return f"login:{user}" if isinstance(user, str) else "login:<?>"

    if kind == "purchase":
        amount = evt.get("amount")
        return f"purchase:{amount}" if isinstance(amount, int) else "purchase:<?>"

    return "unknown"

print(handle_event({"type": "login", "user": "Alice"}))
print(handle_event({"type": "purchase", "amount": 10}))
print(handle_event({"type": "purchase", "amount": "10"}))

union_collections.py
# Unions inside collections

# list[int|str]
items: list[int | str] = [1, "two", 3, "four"]

# Sum ints, collect strings
numbers: list[int] = [x for x in items if isinstance(x, int)]
strings: list[str] = [x for x in items if isinstance(x, str)]

print("numbers:", numbers)
print("strings:", strings)

# dict values union
settings: dict[str, int | str] = {"timeout": 10, "mode": "fast"}
print("settings:", settings)

Use unions when it genuinely improves the API and reflects reality; avoid unions that get too wide.

union type - a type that accepts values of multiple specified types, written as `Type1 | Type2`
type narrowing - using runtime checks like `isinstance()` to tell the type checker which specific type is in use

Exercise: practical.py

Build a response parser that handles success and error cases