Skip to content

← Precise Type Hints with Literals and Generics step 2 of 26

Medium Primitives

Narrow a Union Without Treating Zero as Missing

You are writing a helper that turns a setting into text for a log line. The setting can be a string, a number, a list of strings, or None. The obvious first check is a trap:

def normalise(value: str | int | float | list[str] | None) -> str:
    if not value:
        return ""
    ...

mypy accepts this, because if not value rules out None. But not value is also true for 0, 0.0, "" and [], so normalise(0) returns "" instead of "0". When only None means missing, check value is None.

assert solve([None, 0, 0.0, "", [], ["", ""]]) == ["", "0", "0.00", "", "", ","]
assert solve([2.0, 2, True, False]) == ["2.00", "2", "True", "False"]

What to write

normalise(value: str | int | float | list[str] | None) -> str converts each type like this:

Input Result
None ""
str The string, unchanged
float f"{value:.2f}"
int str(value)
list[str] ",".join(value)
  • Check None with is None. Then tell str, float and int apart with isinstance, and return from each branch. Once those branches have returned, mypy knows the value is a list[str], so the join needs no cast.
  • Use no Any, cast or # type: ignore.
  • Keep strings exactly as they are, including spaces and "".
  • Join every list element in order, including empty strings. Do not sort, trim or drop elements.

solve(values: list[str | int | float | list[str] | None]) -> list[str] returns a new list with normalise applied to each value, in order. It keeps duplicates and leaves the input unchanged. solve([]) returns [].

Edge cases

  • bool is a subclass of int, so True and False take the int branch and give "True" and "False". Do not turn them into 1 and 0, and do not treat False as missing.
  • 2 gives "2" and 2.0 gives "2.00". At runtime isinstance(2, float) and isinstance(2.0, int) are both False, so each number reaches its own branch.
  • Floats use Python's own formatting and rounding. -0.0 gives "-0.00", and 2.675 gives "2.67", because the stored float is slightly below 2.675.
  • [] gives "", and ["", "a,b", ""] gives ",a,b,".

The diagram follows a smaller int | str | None union. Each check removes a type, and a return ends that branch, so later checks see only what is left.

Loading visualization…

Going further

  • mypy narrows on truthiness too. For a str | None, a value that fails if value is either None or the empty string, and mypy tracks both.
  • mypy keeps an attribute such as self.x narrowed after you call a method, even if that method sets self.x = None. Copy the attribute to a local variable before you check it.
  • mypy's guide to narrowing lists the other checks mypy understands.