Work with Optional values¶
The question. You have a function that might return X or might return None — a lookup, a parse, a cache hit — and the type-checker insists you handle both cases before you can treat the result as an X. You want a clean pattern for narrowing X | None to X, plus the common pitfalls around defaults.
The canonical pattern is guard with is not None (not if x: — that hits 0, "", [] too). Either inline the guard or early-return; the type-checker narrows the type inside the guard or past the return.
def find_email(user_id: int) -> str | None:
if user_id == 1:
return 'alice@example.com'
return None
# Pattern 1: inline if — narrows inside the block
email = find_email(1)
if email is not None:
# type-checker knows email is str here — .upper() is safe
print('inline:', email.upper())
# Pattern 2: early return — narrows past the guard
def send_welcome(user_id: int) -> None:
email = find_email(user_id)
if email is None:
return # past this line, email: str
print(f'Sending welcome to {email.upper()}')
send_welcome(1)
send_welcome(99) # returns silently
# Pattern 3: default via ternary — clearer than `or` for the None case
def with_timeout(timeout: float | None = None) -> float:
# ternary, not `or`: 0.0 is valid here, `or` would wrongly replace it
return timeout if timeout is not None else 30.0
print('defaults:', with_timeout(), with_timeout(5.0), with_timeout(0.0))
# Variant: dict .get() with defaults — narrows automatically
data: dict[str, str] = {'name': 'Alice'}
# With a default: return type is str (not str | None)
name = data.get('name', 'unknown')
email = data.get('email', 'no email')
# Without a default: return type is str | None
maybe_phone = data.get('phone')
if maybe_phone is not None:
print('phone:', maybe_phone)
print(name, '|', email, '|', maybe_phone)
# Variant: return empty collections, not None, when callers will iterate
def find_matches_bad(pattern: str, haystack: list[str]) -> list[str] | None:
matches = [s for s in haystack if pattern in s]
return matches if matches else None # forces every caller to check for None
def find_matches(pattern: str, haystack: list[str]) -> list[str]:
return [s for s in haystack if pattern in s] # empty list means 'no matches'
# With the good version, the loop just doesn't run — no None check needed
for m in find_matches('zz', ['a', 'b']):
print(m)
print('done')
Why it works¶
Optional[X] and X | None are the same thing — a union with None. The type-checker follows narrowing: if you prove the value isn't None (via is not None, isinstance, or assert x is not None), the inferred type inside the guard collapses to X. Past an early return, the same thing happens — the checker knows that if execution made it past the guard, the value can't be None.
is not None is the right test because it matches exactly one value. Truthiness (if x:) is false for None, 0, "", [], {}, and False — any of which might be a legitimate non-None value for your type. The ternary defaulting pattern (x if x is not None else default) sidesteps this trap entirely.
The walrus operator (if (user := find_user(1)) is not None and (email := user.email) is not None:) is the readable way to chain Optionals when you need to reach through two layers — it binds the intermediate value so you don't recompute or rebind. Use it when it clarifies; use a temporary variable when it doesn't.
Trade-offs¶
x or default is a footgun on non-string types. name or 'stranger' is fine if "" should be treated as missing. count or 100 silently replaces 0 with 100 — almost always a bug for integer counts. Prefer the ternary x if x is not None else default when you specifically mean "None means use the default".
assert x is not None tells the checker and the runtime. It's fine for internal invariants, dangerous for user-facing code — an assert raises AssertionError at runtime if the assumption is wrong, which is a thin error for anyone debugging. Prefer if x is None: raise ValueError(...) for anything that could bite users.
Return empty collections, not None, when the caller wants to iterate. list[str] | None forces every caller to if result is not None:. list[str] that can be empty lets callers loop without guarding — the for-loop over [] just does nothing. Reach for Optional when there's a meaningful difference between "failed" and "succeeded with no results"; for most collection-returning functions, there isn't.
Dict .get(key, default) narrows for free. With a default, the return type collapses to the value type — no | None. Without one, it's V | None and you need to narrow. A small thing, but a nice reason to always pass the default when you have one.
Related reading¶
- Type a function signature —
X | None = Noneand related parameter patterns. - Avoid common typing mistakes — the
= Noneversus| None = Nonetrap in detail. - Truthiness rules — why
if x:hits too many values.