We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Type System as a Design Tool step 20 of 24
Narrowing without the truthiness trap
if not name:
raise ValueError("name required")
# ... code here assumes name is not None
That is a real bug pattern, and it fails in both directions. It rejects the
empty string when the caller legitimately passed one, and — the half people
miss — it does not actually prove name is not None to the checker in the way
you think.
Narrowing is the checker’s flow analysis: after certain expressions, a variable has a more precise type inside a branch. Here is the full catalogue of forms mypy understands, with what each one leaves behind.
| form | positive branch | negative branch |
|---|---|---|
isinstance(x, C) |
C (and subclasses) |
the rest of the union |
isinstance(x, (A, B)) |
A | B |
the rest |
x is None |
None |
union minus None |
type(x) is C |
exactly C — subclasses excluded |
the rest |
assert isinstance(x, C) |
C for the rest of the scope |
— |
if (y := f()) is not None: |
walrus narrows y |
— |
callable(x) |
a callable type | the rest |
x == "literal" |
narrows a Literal union |
the rest |
x is Colour.RED |
that enum member | the rest |
match x: case C(): ... |
C |
per-arm |
a call returning Never |
code after it is unreachable | — |
truthiness (if x:) |
see below | see below |
The centrepiece: the truthiness trap
Narrow a str | None with if x: and ask mypy what is in the else branch:
reveal_type(x) # Revealed type is "Union[Literal[''], None]"
Literal[''] | None. Not None. The checker is being precise and the code is
being wrong: the empty string took the same path as the missing value. For
int | None, the else branch keeps Literal[0]. For list[str] | None, the
empty list. Every falsy value of every member type survives into the branch you
wrote for “absent”.
Truthiness narrowing is fine when you genuinely mean “falsy”. It is a bug
whenever you meant “missing”, and is None is the fix.
The task
def normalise(value: str | int | float | list[str] | None) -> str: ...
def solve(values: list[str | int | float | list[str] | None]) -> list[str]: ...
| input | output |
|---|---|
None |
"" |
str |
the string itself, unchanged |
float |
fixed two decimals, e.g. "0.00", "-2.25" |
int |
str(value) |
list[str] |
",".join(value) |
solve maps a list through it.
The test inputs deliberately include 0, 0.0, "" and [], so any
truthiness-based implementation produces wrong output: 0 must become
"0", not ""; 0.0 must become "0.00", not ""; the empty list must
become "" via join, which happens to agree — but only by accident, and the
0 cases will not.
2.0 becomes "2.00" while 2 becomes "2", so int and float really
must be distinguished. Note that isinstance(2, float) is False and
isinstance(2.0, int) is False, so the order of those two checks does not
matter — but the order relative to bool would, if bool were in the union.
Types
No cast, no Any, no # type: ignore. Every branch narrows the union to
exactly one member, and the final fall-through is list[str] with nothing else
left — which means ",".join(value) type-checks with no help.
Two facts about narrowing that this problem cannot show you but that you will need:
-
Narrowing is discarded across a function boundary. If you narrow
xand then call a helper, the helper knows nothing about it. Pass the narrowed value as an argument with the narrow type. -
Narrowing on an attribute is discarded by any intervening call, because
the call could have mutated it.
if self.conn is not None: log(); self.conn.send()will not type-check, and it should not. Bind it to a local first:conn = self.conn; if conn is not None: ....
Loading visualization…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.