What I learned
Mypy won’t assume you’ve handled Union[T, None] unless it sees an explicit check (e.g. if some_var is None:...) or an assert some_var is not None.
Context
My team was working on a function that had different behaviours based on whether an injected dependency was None or not (an admittedly sub-optimal MVP shortcut). It looked something like this:
from typing import Optional
class Settings:
def __init__(self, flag: bool) -> None:
self.flag = flag
def _ensure_settings(s: Settings) -> None:
if not isinstance(s, Settings):
raise ValueError("Settings required")
def run_job(data: bytes, settings: Optional[Settings] = None) -> None:
# …some setup…
# ❌ mypy: Argument 1 has incompatible type "Settings | None"
_ensure_settings(settings)
# ❌ mypy: Item "None" of "Settings | None" has no attribute "flag"
if settings.flag:
print("doing the thing…")
To which mypy raises an error Item "None" of "Settings | None" has no attribute "flag".
This happens because the mypy doesn’t change the scope unless it sees an explicit check for None or an assertion that the variable is not None. There’s also another error because the ensure_settings function expects a Settings object, but it can receive None.
The fix here is to add an explicit None check and return an instance of Settings if it isn’t None:
# ✅ update to Optional[Settings]
def _ensure_settings(s: Optional[Settings]) -> Settings:
# ✅ check for None
if s is None or not isinstance(s, Settings):
raise ValueError("Settings required")
# ✅ return Settings instance
return s
Note the Optional[Settings] type hint as well as returning s.