Type Hints
Union Types
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
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"))
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: tendef stringify(x: int | str) -> str:
pass 1 of 218def stringify(x123: int | str) -> str:19 return str(x123)print("stringify(123) =", stringify(123))
21print("stringify(123) =", stringify(123))22print("stringify('abc') =", stringify("abc"))outputstringify(123) = 123def stringify(x: int | str) -> str:
pass 2 of 218def stringify(xabc: int | str) -> str:19 return str(xabc)print("stringify('abc') =", stringify("abc"))
21print("stringify(123) =", stringify(123))22print("stringify('abc') =", stringify("abc"))outputstringify('abc') = abc
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: tendef stringify(x: int | str) -> str:
pass 1 of 217def stringify(x123: int | str) -> str:18 return str(x123)print("stringify(123) =", stringify(123))
20print("stringify(123) =", stringify(123))21print("stringify('abc') =", stringify("abc"))outputstringify(123) = 123def stringify(x: int | str) -> str:
pass 2 of 217def stringify(xabc: int | str) -> str:18 return str(xabc)print("stringify('abc') =", stringify("abc"))
20print("stringify(123) =", stringify(123))21print("stringify('abc') =", stringify("abc"))outputstringify('abc') = abc
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: tendef stringify(x: int | str) -> str:
pass 1 of 217def stringify(x123: int | str) -> str:18 return str(x123)print("stringify(123) =", stringify(123))
20print("stringify(123) =", stringify(123))21print("stringify('abc') =", stringify("abc"))outputstringify(123) = 123def stringify(x: int | str) -> str:
pass 2 of 217def stringify(xabc: int | str) -> str:18 return str(xabc)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"))
print("double(10) =", double(10))
12print("double(10) =", double(10))13print("double('hi') =", double("hi"))def double(x: int | str) -> int | str: # Narrow to int
pass 1 of 24def double(x10: int | str) -> int | str:5 # Narrow to int6 if isinstance(x, int):7 return x * 2if isinstance(x, int):
5# Narrow to int6if isinstance(x10, int):7 return x10 * 2print("double(10) =", double(10))
12print("double(10) =", double(10))13print("double('hi') =", double("hi"))outputdouble(10) = 20def double(x: int | str) -> int | str: # Narrow to int
pass 2 of 24def double(xhi: int | str) -> int | str:5 # Narrow to int6 if isinstance(x, int):7 return x * 289 # Narrow to str10 return xhi + xprint("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"))
print(describe(None))
16print(describe(None))17print(describe(7))def describe(x: int | str | None) -> str:
pass 1 of 34def describe(xNone: int | str | None) -> str:5 match x:6 case None:All 3 passes — pass 1 is the card above pass xns1 None — — 2 7 7 — 3 ok — ok match x:
pass 1 of 34def describe(x: int | str | None) -> str:5 match xNone:6 case None:7 return "none"All 3 passes — pass 1 is the card above pass xns1 None — — 2 7 7 — 3 ok — ok print(describe(None))
16print(describe(None))17print(describe(7))18print(describe("ok"))outputnonecase int() as n:
7 return "none"8case int() as n7:9 return f"int:{n7}"10case str() as s:print(describe(7))
16print(describe(None))17print(describe(7))18print(describe("ok"))outputint:7case str() as s:
9 return f"int:{n}"10case str() as sok:11 return f"str:{sok}"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"}))
print(handle_event({"type": "login", "user": "Alice"}))
22print(handle_event({"type": "login", "user": "Alice"}))23print(handle_event({"type": "purchase", "amount": 10}))kind ← login
pass 1 of 39def 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 pass evtkinduseramount1 {'type': 'login', 'user': 'Alice'} login Alice — 2 {'type': 'purchase', 'amount': 10} purchase — 10 3 {'type': 'purchase', 'amount': '10'} purchase — 10 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:<?>"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:Aliceamount ← 10
pass 1 of 216if kindpurchase == "purchase":17 amount→ 10 = evt{'type': 'purchase', 'amount': 10}.get("amount")18 return f"purchase:{amount10}" if isinstance(amount, int) else "purchase:<?>"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:10amount ← 10
pass 2 of 216if kindpurchase == "purchase":17 amount→ 10 = evt{'type': 'purchase', 'amount': '10'}.get("amount")18 return f"purchase:{amount10}" if isinstance(amount, int) else "purchase:<?>"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)
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