Skip to content

← The Type System as a Design Tool step 20 of 24

Medium Primitives

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 x and 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…