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
.