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:

 1from typing import Optional
 2
 3class Settings:
 4    def __init__(self, flag: bool) -> None:
 5        self.flag = flag
 6
 7def _ensure_settings(s: Settings) -> None:
 8    if not isinstance(s, Settings):
 9        raise ValueError("Settings required")
10
11def run_job(data: bytes, settings: Optional[Settings] = None) -> None:
12    # …some setup…
13    # ❌ mypy: Argument 1 has incompatible type "Settings | None"
14    _ensure_settings(settings)
15
16    # ❌ mypy: Item "None" of "Settings | None" has no attribute "flag"
17    if settings.flag:
18        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:

1# âś… update to Optional[Settings]
2def _ensure_settings(s: Optional[Settings]) -> Settings:
3    # âś… check for None
4    if s is None or not isinstance(s, Settings):
5        raise ValueError("Settings required")
6    # âś… return Settings instance
7    return s

Note the Optional[Settings] type hint as well as returning s.