Type Hints
Optional Types (and None)
Database lookups, configuration values, and user input often produce "nothing found" results. Optional types make the possibility of None explicit in your code, forcing you to handle missing values before type checkers let you use the result.
In Python, None is commonly used to represent "missing" or "not found". Type hints make this explicit.
Optional Basics
optional_basics.py
Replay: real traced execution (multi-file project)
# Optional basics
from typing import Optional
# Two equivalent ways
name1: Optional[str] = "Alice"
name2: str | None = None
print("name1:", name1)
print("name2:", name2)
# Function returning optional
def lookup_city(user_id: int) -> str | None:
cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}
return cities.get(user_id)
print("lookup_city(1) =", lookup_city(1))
print("lookup_city(99) =", lookup_city(99))
# Optional basics
from typing import Optional
# Two equivalent ways
name1: Optional[str] = "Alice"
name2: str | None = "Maya"
print("name1:", name1)
print("name2:", name2)
# Function returning optional
def lookup_city(user_id: int) -> str | None:
cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}
return cities.get(user_id)
print("lookup_city(1) =", lookup_city(1))
print("lookup_city(99) =", lookup_city(99))
# Optional basics
from typing import Optional
# Two equivalent ways
name1: Optional[str] = "Alice"
name2: str | None = "Jordan"
print("name1:", name1)
print("name2:", name2)
# Function returning optional
def lookup_city(user_id: int) -> str | None:
cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}
return cities.get(user_id)
print("lookup_city(1) =", lookup_city(1))
print("lookup_city(99) =", lookup_city(99))
name1 ← Alice, name2 ← None
5# Two equivalent ways6name1→ Alice: Optional[str] = "Alice"7name2→ None: str | None = NoneNone8#@name2="Maya", "Jordan"910print("name1:", name1Alice)11print("name2:", name2None)1213# Function returning optional1415def lookup_city(user_id: int) -> str | None:16 cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}17 return cities.get(user_id)1819print("lookup_city(1) =", lookup_city(1))20print("lookup_city(99) =", lookup_city(99))outputname1: Alice name2: Nonecities ← {1: 'Paris', 2: 'Tokyo'}
pass 1 of 215def lookup_city(user_id1: int) -> str | None:16 cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}17 return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id1)print("lookup_city(1) =", lookup_city(1))
19print("lookup_city(1) =", lookup_city(1))20print("lookup_city(99) =", lookup_city(99))outputlookup_city(1) = Pariscities ← {1: 'Paris', 2: 'Tokyo'}
pass 2 of 215def lookup_city(user_id99: int) -> str | None:16 cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}17 return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id99)print("lookup_city(99) =", lookup_city(99))
19print("lookup_city(1) =", lookup_city(1))20print("lookup_city(99) =", lookup_city(99))outputlookup_city(99) = None
name1 ← Alice, name2 ← Maya
5# Two equivalent ways6name1→ Alice: Optional[str] = "Alice"7name2→ Maya: str | None = "Maya"89print("name1:", name1Alice)10print("name2:", name2Maya)1112# Function returning optional1314def lookup_city(user_id: int) -> str | None:15 cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}16 return cities.get(user_id)1718print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))outputname1: Alice name2: Mayacities ← {1: 'Paris', 2: 'Tokyo'}
pass 1 of 214def lookup_city(user_id1: int) -> str | None:15 cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}16 return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id1)print("lookup_city(1) =", lookup_city(1))
18print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))outputlookup_city(1) = Pariscities ← {1: 'Paris', 2: 'Tokyo'}
pass 2 of 214def lookup_city(user_id99: int) -> str | None:15 cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}16 return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id99)print("lookup_city(99) =", lookup_city(99))
18print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))outputlookup_city(99) = None
name1 ← Alice, name2 ← Jordan
5# Two equivalent ways6name1→ Alice: Optional[str] = "Alice"7name2→ Jordan: str | None = "Jordan"89print("name1:", name1Alice)10print("name2:", name2Jordan)1112# Function returning optional1314def lookup_city(user_id: int) -> str | None:15 cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}16 return cities.get(user_id)1718print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))outputname1: Alice name2: Jordancities ← {1: 'Paris', 2: 'Tokyo'}
pass 1 of 214def lookup_city(user_id1: int) -> str | None:15 cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}16 return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id1)print("lookup_city(1) =", lookup_city(1))
18print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))outputlookup_city(1) = Pariscities ← {1: 'Paris', 2: 'Tokyo'}
pass 2 of 214def lookup_city(user_id99: int) -> str | None:15 cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}16 return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id99)print("lookup_city(99) =", lookup_city(99))
18print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))outputlookup_city(99) = None
Optional[T] - a type alias meaning `T | None`, indicating the value might be missing
Type Narrowing
Type checkers can narrow an optional value after checks:
narrowing.py
Replay: real traced execution (multi-file project)
# Narrowing optionals
def greet(name: str | None) -> str:
# Narrowing with is None
if name is None:
return "Hello, stranger"
# Here name is treated as str by type checkers
return f"Hello, {name.upper()}"
print(greet("Alice"))
print(greet(None))
# Guard function
def ensure_str(value: str | None) -> str:
if value is None:
raise ValueError("value is required")
return value
print("ensure_str('x') =", ensure_str("x"))
print(greet("Alice"))
11print(greet("Alice"))12print(greet(None))def greet(name: str | None) -> str: # Narrowing with is None
pass 1 of 23def greet(nameAlice: str | None) -> str:4 # Narrowing with is None5 if name is None:6 return "Hello, stranger"78 # Here name is treated as str by type checkers9 return f"Hello, {nameAlice.upper()}"print(greet("Alice"))
11print(greet("Alice"))12print(greet(None))outputHello, ALICEdef greet(name: str | None) -> str: # Narrowing with is None
pass 2 of 23def greet(nameNone: str | None) -> str:4 # Narrowing with is None5 if name is None:6 return "Hello, stranger"if name is None:
4# Narrowing with is None5if nameNone is None:6 return "Hello, stranger"print(greet(None))
11print(greet("Alice"))12print(greet(None))1314# Guard function1516def ensure_str(value: str | None) -> str:17 if value is None:18 raise ValueError("value is required")19 return value2021print("ensure_str('x') =", ensure_str("x"))outputHello, strangerdef ensure_str(value: str | None) -> str:
16def ensure_str(valuex: str | None) -> str:17 if value is None:18 raise ValueError("value is required")19 return valuexprint("ensure_str('x') =", ensure_str("x"))
21print("ensure_str('x') =", ensure_str("x"))outputensure_str('x') = x
narrowing - using `if x is not None:` checks to prove to the type checker that a value exists
Optional in Collections
optional_collections.py
Replay: real traced execution (multi-file project)
# Optional with collections
from typing import Optional
# dict.get returns Optional[V]
ages: dict[str, int] = {"Alice": 30, "Bob": 27}
maybe_age: Optional[int] = ages.get("Charlie")
print("maybe_age:", maybe_age)
# Provide a default to avoid Optional
age_or_zero: int = ages.get("Charlie", 0)
print("age_or_zero:", age_or_zero)
# Optional element in list
scores: list[int | None] = [10, None, 30]
# Filter out None safely
clean: list[int] = [s for s in scores if s is not None]
print("clean:", clean)
ages ← {'Alice': 30, 'Bob': 27}, maybe_age ← None, age_or_zero ← 0
5# dict.get returns Optional[V]6ages→ {'Alice': 30, 'Bob': 27}: dict[str, int] = {"Alice": 30, "Bob": 27}78maybe_age→ None: Optional[int] = ages{'Alice': 30, 'Bob': 27}.get("Charlie")9print("maybe_age:", maybe_ageNone)1011# Provide a default to avoid Optional12age_or_zero→ 0: int = ages{'Alice': 30, 'Bob': 27}.get("Charlie", 0)13print("age_or_zero:", age_or_zero0)1415# Optional element in list16scores→ [10, None, 30]: list[int | None] = [10, None, 30]1718# Filter out None safely19clean→ [10, 30]: list[int] = [s for s in scores[10, None, 30] if s is not None]20print("clean:", clean[10, 30])outputmaybe_age: None age_or_zero: 0 clean: [10, 30]
Sentinel Values
Sometimes None is a valid value. In that case, use a sentinel object to represent "not provided".
sentinel.py
Replay: real traced execution (multi-file project)
# Sentinel values
from typing import Any
# Sentinel object
MISSING = object()
# None could be a valid value, so we need a different “not provided” marker.
def get_setting(settings: dict[str, Any], key: str, default: Any = MISSING) -> Any:
value = settings.get(key, MISSING)
if value is not MISSING:
return value
if default is MISSING:
raise KeyError(key)
return default
cfg: dict[str, Any] = {"timeout": None, "retries": 3}
print("timeout (explicit None) =", get_setting(cfg, "timeout"))
print("retries =", get_setting(cfg, "retries"))
print("missing with default =", get_setting(cfg, "missing", 0))
MISSING ← ⟨object A⟩, cfg ← {'timeout': None, 'retries': 3}
5# Sentinel object6MISSING→ ⟨object A⟩ = object()78# None could be a valid value, so we need a different “not provided” marker.910def get_setting(settings: dict[str, Any], key: str, default: Any = MISSING) -> Any:11 value = settings.get(key, MISSING)12 if value is not MISSING:13 return value1415 if default is MISSING:16 raise KeyError(key)1718 return default1920cfg→ {'timeout': None, 'retries': 3}: dict[str, Any] = {"timeout": None, "retries": 3}2122print("timeout (explicit None) =", get_setting(cfg{'timeout': None, 'retries': 3}, "timeout"))23print("retries =", get_setting(cfg, "retries"))value ← None
pass 1 of 310def get_setting(settings{'timeout': None, 'retries': 3}: dict[str, Any], keytimeout: str, default⟨object A⟩: Any = MISSING⟨object A⟩) -> Any:11 value→ None = settings{'timeout': None, 'retries': 3}.get(keytimeout, MISSING⟨object A⟩)12 if value is not MISSING:All 3 passes — pass 1 is the card above pass keydefaultvalue1 timeout ⟨object A⟩ None 2 retries ⟨object A⟩ 3 3 missing 0 ⟨object A⟩ if value is not MISSING:
pass 1 of 211value = settings.get(key, MISSING)12if valueNone is not MISSING⟨object A⟩:13 return valueNoneprint("timeout (explicit None) =", get_setting(cfg, "timeout"))
22print("timeout (explicit None) =", get_setting(cfg{'timeout': None, 'retries': 3}, "timeout"))23print("retries =", get_setting(cfg{'timeout': None, 'retries': 3}, "retries"))24print("missing with default =", get_setting(cfg, "missing", 0))outputtimeout (explicit None) = Noneif value is not MISSING:
pass 2 of 211value = settings.get(key, MISSING)12if value3 is not MISSING⟨object A⟩:13 return value3print("retries =", get_setting(cfg, "retries"))
22print("timeout (explicit None) =", get_setting(cfg, "timeout"))23print("retries =", get_setting(cfg{'timeout': None, 'retries': 3}, "retries"))24print("missing with default =", get_setting(cfg{'timeout': None, 'retries': 3}, "missing", 0))outputretries = 3print("missing with default =", get_setting(cfg, "missing", 0))
23print("retries =", get_setting(cfg, "retries"))24print("missing with default =", get_setting(cfg{'timeout': None, 'retries': 3}, "missing", 0))outputmissing with default = 0
sentinel - a unique object used when `None` is a valid value and you need to distinguish "not provided"
Parsing Optional Values
parse_optional.py
Replay: real traced execution (multi-file project)
# Parsing that can fail
from typing import Optional
# Optional parse
def try_parse_int(text: str) -> Optional[int]:
try:
return int(text)
except ValueError:
return None
inputs = ["10", "x", "42"]
parsed: list[int] = []
for t in inputs:
value = try_parse_int(t)
if value is None:
print("skip:", t)
continue
parsed.append(value)
print("parsed:", parsed)
# Alternative: raise instead of Optional
def parse_int(text: str) -> int:
return int(text)
print("parse_int('7') =", parse_int("7"))
inputs ← ['10', 'x', '42'], parsed ← []
13inputs→ ['10', 'x', '42'] = ["10", "x", "42"]14parsed→ []: list[int] = []for t in inputs:
pass 1 of 316for t10 in inputs['10', 'x', '42']:17 value = try_parse_int(t10)18 if value is None:All 3 passes — pass 1 is the card above pass t1 10 2 x 3 42 def try_parse_int(text: str) -> Optional[int]:
pass 1 of 37def try_parse_int(text10: str) -> Optional[int]:8 try:9 return int(text)All 3 passes — pass 1 is the card above pass text1 10 2 x 3 42 try:
pass 1 of 37def try_parse_int(text: str) -> Optional[int]:8 try:9 return int(text10)10 except ValueError:All 3 passes — pass 1 is the card above pass text1 10 2 x 3 42 value ← 10, parsed ← [10]
16for t in inputs:17 value→ 10 = try_parse_int(t10)18 if value is None:19 print("skip:", t)20 continue21 parsed→ [10].append(value10)value ← None
16for t in inputs:17 value→ None = try_parse_int(tx)18 if value is None:if value is None:
17value = try_parse_int(t)18if valueNone is None:19 print("skip:", tx)20 continue21parsed.append(value)outputskip: xvalue ← 42, parsed ← [10, 42]
16for t in inputs:17 value→ 42 = try_parse_int(t42)18 if value is None:19 print("skip:", t)20 continue21 parsed→ [10, 42].append(value42)print("parsed:", parsed)
23print("parsed:", parsed[10, 42])2425# Alternative: raise instead of Optional2627def parse_int(text: str) -> int:28 return int(text)2930print("parse_int('7') =", parse_int("7"))outputparsed: [10, 42]def parse_int(text: str) -> int:
27def parse_int(text7: str) -> int:28 return int(text7)print("parse_int('7') =", parse_int("7"))
30print("parse_int('7') =", parse_int("7"))outputparse_int('7') = 7
Exercise: practical.py
Build a config loader that handles missing keys gracefully