Type narrowing
See how checkers narrow unions through is None, isinstance and equality, write your own narrowing functions with TypeIs and TypeGuard, and make match statements provably exhaustive with assert_never.
What you will learn
- Predict the narrowed type in each branch of an if statement
- Write a type predicate with TypeIs, and explain how it differs from TypeGuard
- Use class patterns in match to take apart dataclasses
- Make a match or if chain exhaustive with assert_never, so new cases cannot be forgotten
On this page
A union such as int | str | None says a value may be one of several types. Before you can use it, you must find out which. Checkers follow your if statements and narrow the type in each branch. You have relied on this since Static typing fundamentals, where an isinstance check made an object usable. This lesson shows the rules, how to extend them, and how to turn “I handled every case” into something the checker proves.
Built-in narrowing
is None, isinstance, == against literals, in, and plain truthiness all narrow. After a branch returns, the rest of the function sees what is left:
from typing import reveal_type
def describe(value: int | str | None) -> str: if value is None: reveal_type(value) return "nothing" reveal_type(value) if isinstance(value, str): reveal_type(value) return f"text of length {len(value)}" reveal_type(value) return f"number {value + 1}"
print(describe(None), describe("abc"), describe(41), sep=" | ")nothing | text of length 3 | number 42$ mypy narrow_basics.pynarrow_basics.py:6: note: Revealed type is "None"narrow_basics.py:8: note: Revealed type is "int | str"narrow_basics.py:10: note: Revealed type is "str"narrow_basics.py:12: note: Revealed type is "int"Success: no issues found in 1 source fileLine 8 runs only when value is not None, so None has been removed. Inside the isinstance branch it is str. After that branch returns, only int is left, so value + 1 is accepted. Narrowing works on local names and, with some care, on attributes. It does not survive a function call that might change the attribute, so copy an attribute into a local before testing it.
Your own predicates: TypeIs
A helper such as is_text(value) hides the isinstance from the checker, so by default nothing narrows. Annotate its return as TypeIs[str] (PEP 742) and the checker treats a call like the isinstance inside it:
from typing import TypeIs, reveal_type
def is_text(value: object) -> TypeIs[str]: return isinstance(value, str)
def shout(value: int | str) -> str: if is_text(value): reveal_type(value) return value.upper() reveal_type(value) return str(value * 10)
print(is_text("hi"), is_text(3), type(is_text("hi")).__name__)print(shout("hi"), shout(3))True False boolHI 30$ mypy typeis_basic.pytypeis_basic.py:10: note: Revealed type is "str"typeis_basic.py:12: note: Revealed type is "int"Success: no issues found in 1 source fileAt run time is_text is an ordinary function returning bool, as the first output line shows. TypeIs[str] is a promise to the checker: “True means the argument is a str, and False means it is not”. The checker uses both halves: str in the if branch, and int in the else branch, since str has been removed from int | str.
TypeGuard: the older, weaker form
TypeGuard (Python 3.10) looks similar but promises less: True means the type, and False means nothing at all. Compare the negative branches:
from typing import TypeGuard, TypeIs, reveal_type
def guard_text(value: object) -> TypeGuard[str]: return isinstance(value, str)
def is_text(value: object) -> TypeIs[str]: return isinstance(value, str)
def check(value: int | str) -> None: if not guard_text(value): reveal_type(value) if not is_text(value): reveal_type(value)
check(5)print(guard_text("x"), is_text(5))True False$ mypy typeguard_vs_typeis.pytypeguard_vs_typeis.py:14: note: Revealed type is "int | str"typeguard_vs_typeis.py:16: note: Revealed type is "int"Success: no issues found in 1 source fileAfter not guard_text(value), the type is still int | str. After not is_text(value), it is int.
TypeGuard remains useful for one thing TypeIs forbids: narrowing to a type that is not a subtype of the input. list[str] is not a subtype of list[object], because lists are invariant, so TypeIs[list[str]] on a list[object] parameter is an error (Narrowed type "list[str]" is not a subtype of input type "list[object]", in mypy’s words). TypeGuard allows it:
from typing import TypeGuard
def all_text(items: list[object]) -> TypeGuard[list[str]]: return all(isinstance(item, str) for item in items)
def join(items: list[object]) -> str: if all_text(items): return "-".join(items) return "mixed"
print(join(["a", "b"]), join(["a", 1]))a-b mixed$ mypy typeguard_list.pySuccess: no issues found in 1 source fileUse TypeIs by default, and TypeGuard only for this kind of conversion.
Exhaustiveness with assert_never
A union of dataclasses is a common way to model “one of these shapes”. Here is the union, written with the type statement from Generics with type parameter syntax:
from dataclasses import dataclass
@dataclassclass Circle: radius: float
@dataclassclass Square: side: float
type Shape = Circle | Squarematch takes such values apart with class patterns. case Circle(radius=r) matches when the subject is a Circle, and binds its radius attribute to r. Inside each case, the checker narrows the subject to that class. The wildcard case _ catches everything else, and here everything else should be nothing:
from typing import assert_never
from shapes import Circle, Shape, Square
def area(shape: Shape) -> float: match shape: case Circle(radius=r): return 3.14159 * r * r case Square(side=s): return s * s case _: assert_never(shape)
print(round(area(Circle(1.0)), 2), area(Square(3.0)))3.14 9.0$ mypy match_never.pySuccess: no issues found in 1 source fileassert_never(arg) is annotated to accept only Never, the type with no values. After Circle and Square are handled, the checker has narrowed shape to Never, so the call type-checks. Now add a third shape and forget to handle it:
from dataclasses import dataclassfrom typing import assert_never
from shapes import Circle, Square
@dataclassclass Triangle: side: float
def area(shape: Circle | Square | Triangle) -> float: match shape: case Circle(radius=r): return 3.14159 * r * r case Square(side=s): return s * s case _: assert_never(shape)
try: area(Triangle(3.0))except AssertionError as error: print("AssertionError:", error)AssertionError: Expected code to be unreachable, but got: Triangle(side=3.0)$ mypy match_missing.pymatch_missing.py:19: error: Argument 1 to "assert_never" has incompatible type "Triangle"; expected "Never" [arg-type]Found 1 error in 1 file (checked 1 source file)The checker reports the forgotten case by name, before any triangle is ever drawn. At run time assert_never raises AssertionError with the offending value, so the mistake is loud either way. The same works with if chains over a Literal or an enum:
from typing import Literal, assert_never
type Status = Literal["ok", "warn", "fail"]
def colour(status: Status) -> str: if status == "ok": return "green" elif status == "warn": return "amber" elif status == "fail": return "red" else: assert_never(status)
print([colour(s) for s in ("ok", "warn", "fail")])['green', 'amber', 'red']$ mypy status_never.pySuccess: no issues found in 1 source fileThis is the main reason to type a closed set of cases as a union or Literal: adding a case turns every incomplete handler into a checker error. Pattern matching in depth covers the rest of match: mapping patterns, guards and more class patterns.
When not to write a predicate
Inline isinstance and is None checks narrow without any help, and a reader sees the test where it happens. Write a TypeIs function only when the same check repeats in several places, or is too long to read inline. assert_never belongs where the set of cases is closed and will grow, like message types or states. For an open set, such as arbitrary user input, a final else that reports the bad value is more honest than claiming exhaustiveness.
Common pitfalls
A predicate that lies. TypeIs is trusted, not checked. If the function returns False for some values of the narrowed type, the else branch will be wrong. The third exercise shows the bug.
A wildcard that swallows new cases. case _: return "unknown" makes a match exhaustive in a useless way. Put assert_never in the wildcard, so additions are reported.
Narrowing an attribute, then calling something. After if self.user is not None:, a method call can set self.user = None. Checkers differ on how much they trust attribute narrowing across calls. Assign to a local first.
Using isinstance with a generic alias. isinstance(x, list[int]) raises TypeError. Check isinstance(x, list), then the items, or use TypeGuard.
Define three event dataclasses in events.py: Click with x and y, Key with name, and Quit with no fields, plus type Event = Click | Key | Quit. Write handle(event) with match: report clicks as click at X,Y, the key "Escape" as escape pressed, other keys as key NAME, and quit as bye. End with assert_never.
Handle [Click(3, 4), Key("a"), Key("Escape"), Quit()]. Expected output, and Success from mypy:
click at 3,4key aescape pressedbyeShow solution
from dataclasses import dataclass
@dataclassclass Click: x: int y: int
@dataclassclass Key: name: str
@dataclassclass Quit: pass
type Event = Click | Key | Quitfrom typing import assert_never
from events import Click, Event, Key, Quit
def handle(event: Event) -> str: match event: case Click(x=x, y=y): return f"click at {x},{y}" case Key(name="Escape"): return "escape pressed" case Key(name=name): return f"key {name}" case Quit(): return "bye" case _: assert_never(event)
events: list[Event] = [Click(3, 4), Key("a"), Key("Escape"), Quit()]for event in events: print(handle(event))click at 3,4key aescape pressedbye$ mypy events_handle.pySuccess: no issues found in 1 source fileKey(name="Escape") uses a literal sub-pattern, so it must come before the general Key(name=name) case. Quit() with no arguments matches any Quit. Remove the Quit() case and mypy reports the missing Quit at the assert_never line, just as it did for the triangle.
Write is_number(value: object) -> TypeIs[int | float] that is true for ints and floats but false for bool, which is a subclass of int. Use it in total(items: list[object]) -> float to add up only the numbers.
Print total([1, "two", 3.5, None, True, 4]), then is_number of 2, "2" and False. Expected output, and Success from mypy:
8.5True False FalseShow solution
from typing import TypeIs
def is_number(value: object) -> TypeIs[int | float]: return isinstance(value, (int, float)) and not isinstance(value, bool)
def total(items: list[object]) -> float: result = 0.0 for item in items: if is_number(item): result += item return result
print(total([1, "two", 3.5, None, True, 4]))print(is_number(2), is_number("2"), is_number(False))8.5True False False$ mypy is_number.pySuccess: no issues found in 1 source fileInside the if, item is int | float, so result += item type-checks. Without TypeIs, item would stay object and the addition would be an error.
mypy reports no errors for this program, yet it crashes. Find the line that breaks the TypeIs promise, explain why the else branch is wrong, and fix show so that it prints +5, ABC and -5.
from typing import TypeIs
def is_positive(value: int | str) -> TypeIs[int]: return isinstance(value, int) and value > 0
def show(value: int | str) -> str: if is_positive(value): return f"+{value}" return value.upper()
values: list[int | str] = [5, "abc", -5]for value in values: try: print(show(value)) except AttributeError as error: print("AttributeError:", error)+5ABCAttributeError: 'int' object has no attribute 'upper'$ mypy positive_bug.pySuccess: no issues found in 1 source fileExpected output after the fix:
+5ABC-5Show solution
is_positive(-5) returns False, so the checker concludes that -5 is not an int and narrows value to str in the rest of show. That is false, and .upper() fails on an int. A TypeIs function must return True for every value of the narrowed type. “Is a positive int” is not a type, so it cannot be a TypeIs. Test the type and the value separately:
def show(value: int | str) -> str: if isinstance(value, str): return value.upper() if value > 0: return f"+{value}" return str(value)
values: list[int | str] = [5, "abc", -5]for value in values: print(show(value))+5ABC-5$ mypy positive_fixed.pySuccess: no issues found in 1 source fileAnnotating is_positive with TypeGuard[int] would also have been honest, because TypeGuard makes no promise about False.
Summary
- Checkers narrow unions through
is None,isinstance, literal comparisons,inand truthiness, branch by branch. TypeIs[T]narrows in both branches. The function must returnTrueexactly when the argument is aT.TypeGuard[T]narrows only onTrue, and may narrow to a type that is not a subtype of the input.- Class patterns such as
case Circle(radius=r)match a type and bind its attributes. assert_neverin the final branch makes the checker prove amatchorifchain is exhaustive.