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.

A union type means a value can be one of several types.

  • Old style: Union[int, str]
  • Modern style (Python 3.10+): int | str

Basic Union Types

v1
union_basics.py
Replay: real traced execution (multi-file project)
# Union basics

from typing import Union

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

v1: Value1 = 10
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 = 99
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 = "ten"
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"))

  1. Value1 ← typing.Union[int, str], Value2 ← int | str, v1 ← 10, v2 ← ten

    5# Two equivalent forms6Value1→ typing.Union[int, str] = Union[int, str]typing.Union[int, str]7Value2→ int | str = int | str89v1→ 10: Value1 = 1010#@v1=99, "ten"11v2→ ten: Value2 = "ten"1213print("v1:", v110)14print("v2:", v2ten)1516# Function parameter union1718def stringify(x: int | str) -> str:19    return str(x)2021print("stringify(123) =", stringify(123))22print("stringify('abc') =", stringify("abc"))
    outputv1: 10
    v2: ten
  2. def stringify(x: int | str) -> str:

    pass 1 of 2
    18def stringify(x123: int | str) -> str:19    return str(x123)
  3. print("stringify(123) =", stringify(123))

    21print("stringify(123) =", stringify(123))22print("stringify('abc') =", stringify("abc"))
    outputstringify(123) = 123
  4. def stringify(x: int | str) -> str:

    pass 2 of 2
    18def stringify(xabc: int | str) -> str:19    return str(xabc)
  5. print("stringify('abc') =", stringify("abc"))

    21print("stringify(123) =", stringify(123))22print("stringify('abc') =", stringify("abc"))
    outputstringify('abc') = abc
  1. Value1 ← typing.Union[int, str], Value2 ← int | str, v1 ← 99, v2 ← ten

    5# Two equivalent forms6Value1→ typing.Union[int, str] = Union[int, str]typing.Union[int, str]7Value2→ int | str = int | str89v1→ 99: Value1 = 9910v2→ ten: Value2 = "ten"1112print("v1:", v199)13print("v2:", v2ten)1415# Function parameter union1617def stringify(x: int | str) -> str:18    return str(x)1920print("stringify(123) =", stringify(123))21print("stringify('abc') =", stringify("abc"))
    outputv1: 99
    v2: ten
  2. def stringify(x: int | str) -> str:

    pass 1 of 2
    17def stringify(x123: int | str) -> str:18    return str(x123)
  3. print("stringify(123) =", stringify(123))

    20print("stringify(123) =", stringify(123))21print("stringify('abc') =", stringify("abc"))
    outputstringify(123) = 123
  4. def stringify(x: int | str) -> str:

    pass 2 of 2
    17def stringify(xabc: int | str) -> str:18    return str(xabc)
  5. print("stringify('abc') =", stringify("abc"))

    20print("stringify(123) =", stringify(123))21print("stringify('abc') =", stringify("abc"))
    outputstringify('abc') = abc
  1. Value1 ← typing.Union[int, str], Value2 ← int | str, v1 ← ten

    5# Two equivalent forms6Value1→ typing.Union[int, str] = Union[int, str]typing.Union[int, str]7Value2→ int | str = int | str89v1→ ten: Value1 = "ten"10v2→ ten: Value2 = "ten"1112print("v1:", v1ten)13print("v2:", v2ten)1415# Function parameter union1617def stringify(x: int | str) -> str:18    return str(x)1920print("stringify(123) =", stringify(123))21print("stringify('abc') =", stringify("abc"))
    outputv1: ten
    v2: ten
  2. def stringify(x: int | str) -> str:

    pass 1 of 2
    17def stringify(x123: int | str) -> str:18    return str(x123)
  3. print("stringify(123) =", stringify(123))

    20print("stringify(123) =", stringify(123))21print("stringify('abc') =", stringify("abc"))
    outputstringify(123) = 123
  4. def stringify(x: int | str) -> str:

    pass 2 of 2
    17def stringify(xabc: int | str) -> str:18    return str(xabc)
  5. print("stringify('abc') =", stringify("abc"))

    20print("stringify(123) =", stringify(123))21print("stringify('abc') =", stringify("abc"))
    outputstringify('abc') = abc
union type - a type that accepts values of multiple specified types, written as `Type1 | Type2`

Narrowing with isinstance

isinstance_narrow.py
Replay: real traced execution (multi-file project)
# 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"))

  1. print("double(10) =", double(10))

    12print("double(10) =", double(10))13print("double('hi') =", double("hi"))
  2. def double(x: int | str) -> int | str: # Narrow to int

    pass 1 of 2
    4def double(x10: int | str) -> int | str:5    # Narrow to int6    if isinstance(x, int):7        return x * 2
  3. if isinstance(x, int):

    5# Narrow to int6if isinstance(x10, int):7    return x10 * 2
  4. print("double(10) =", double(10))

    12print("double(10) =", double(10))13print("double('hi') =", double("hi"))
    outputdouble(10) = 20
  5. def double(x: int | str) -> int | str: # Narrow to int

    pass 2 of 2
    4def double(xhi: int | str) -> int | str:5    # Narrow to int6    if isinstance(x, int):7        return x * 289    # Narrow to str10    return xhi + x
  6. print("double('hi') =", double("hi"))

    12print("double(10) =", double(10))13print("double('hi') =", double("hi"))
    outputdouble('hi') = hihi
type narrowing - using runtime checks like `isinstance()` to tell the type checker which specific type is in use

Pattern Matching

Type checkers narrow unions using structural pattern matching:

match_case.py
Replay: real traced execution (multi-file project)
# 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"))

  1. print(describe(None))

    16print(describe(None))17print(describe(7))
  2. def describe(x: int | str | None) -> str:

    pass 1 of 3
    4def describe(xNone: int | str | None) -> str:5    match x:6        case None:
    All 3 passes — pass 1 is the card above
    passxns
    1None
    277
    3okok
  3. match x:

    pass 1 of 3
    4def describe(x: int | str | None) -> str:5    match xNone:6        case None:7            return "none"
    All 3 passes — pass 1 is the card above
    passxns
    1None
    277
    3okok
  4. print(describe(None))

    16print(describe(None))17print(describe(7))18print(describe("ok"))
    outputnone
  5. case int() as n:

    7    return "none"8case int() as n7:9    return f"int:{n7}"10case str() as s:
  6. print(describe(7))

    16print(describe(None))17print(describe(7))18print(describe("ok"))
    outputint:7
  7. case str() as s:

    9    return f"int:{n}"10case str() as sok:11    return f"str:{sok}"
  8. print(describe("ok"))

    17print(describe(7))18print(describe("ok"))
    outputstr:ok

Tagged Dictionaries

Using explicit tag fields ("type": "...") in dict payloads:

tagged_dict.py
Replay: real traced execution (multi-file project)
# 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"}))

  1. print(handle_event({"type": "login", "user": "Alice"}))

    22print(handle_event({"type": "login", "user": "Alice"}))23print(handle_event({"type": "purchase", "amount": 10}))
  2. kind ← login

    pass 1 of 3
    9def handle_event(evt{'type': 'login', 'user': 'Alice'}: dict[str, Any]) -> str:10    kind→ login = evt{'type': 'login', 'user': 'Alice'}.get("type")
    All 3 passes — pass 1 is the card above
    passevtkinduseramount
    1{'type': 'login', 'user': 'Alice'}loginAlice
    2{'type': 'purchase', 'amount': 10}purchase10
    3{'type': 'purchase', 'amount': '10'}purchase10
  3. user ← Alice

    12if kindlogin == "login":13    user→ Alice = evt{'type': 'login', 'user': 'Alice'}.get("user")14    return f"login:{userAlice}" if isinstance(user, str) else "login:<?>"
  4. print(handle_event({"type": "login", "user": "Alice"}))

    22print(handle_event({"type": "login", "user": "Alice"}))23print(handle_event({"type": "purchase", "amount": 10}))24print(handle_event({"type": "purchase", "amount": "10"}))
    outputlogin:Alice
  5. amount ← 10

    pass 1 of 2
    16if kindpurchase == "purchase":17    amount→ 10 = evt{'type': 'purchase', 'amount': 10}.get("amount")18    return f"purchase:{amount10}" if isinstance(amount, int) else "purchase:<?>"
  6. print(handle_event({"type": "purchase", "amount": 10}))

    22print(handle_event({"type": "login", "user": "Alice"}))23print(handle_event({"type": "purchase", "amount": 10}))24print(handle_event({"type": "purchase", "amount": "10"}))
    outputpurchase:10
  7. amount ← 10

    pass 2 of 2
    16if kindpurchase == "purchase":17    amount→ 10 = evt{'type': 'purchase', 'amount': '10'}.get("amount")18    return f"purchase:{amount10}" if isinstance(amount, int) else "purchase:<?>"
  8. print(handle_event({"type": "purchase", "amount": "10"}))

    23print(handle_event({"type": "purchase", "amount": 10}))24print(handle_event({"type": "purchase", "amount": "10"}))
    outputpurchase:<?>

Unions in Collections

union_collections.py
Replay: real traced execution (multi-file project)
# 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)

  1. items ← [1, 'two', 3, 'four'], numbers ← [1, 3], strings ← ['two', 'four']

    3# list[int|str]4items→ [1, 'two', 3, 'four']: list[int | str] = [1, "two", 3, "four"]56# Sum ints, collect strings7numbers→ [1, 3]: list[int] = [x for x in items[1, 'two', 3, 'four'] if isinstance(x, int)]8strings→ ['two', 'four']: list[str] = [x for x in items[1, 'two', 3, 'four'] if isinstance(x, str)]910print("numbers:", numbers[1, 3])11print("strings:", strings['two', 'four'])1213# dict values union14settings→ {'timeout': 10, 'mode': 'fast'}: dict[str, int | str] = {"timeout": 10, "mode": "fast"}15print("settings:", settings{'timeout': 10, 'mode': 'fast'})
    outputnumbers: [1, 3]
    strings: ['two', 'four']
    settings: {'timeout': 10, 'mode': 'fast'}

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

Exercise: practical.py

Build a response parser that handles success and error cases