Python Module 11 – Type hints and static type checking
Type narrowing, TypeIs and exhaustiveness
Use isinstance, is None and match to narrow types, write your own checks with TypeIs, and let the checker report every case that a branch forgets.
What you will learn
- Use isinstance(), is None, enum members and match cases so that the checker narrows a union in each branch
- Trace a narrowed type with reveal_type() before and during a run
- Write user-defined checks with TypeIs and tell them apart from TypeGuard
- Prove with assert_never() that every member of an enum or a union is handled
- Reproduce the bugs that a wrong check or a stale narrowing lets through
Before you start
What you’ll learn · Before you start
What you will learn
- Use isinstance(), is None, enum members and match cases so that the checker narrows a union in each branch
- Trace a narrowed type with reveal_type() before and during a run
- Write user-defined checks with TypeIs and tell them apart from TypeGuard
- Prove with assert_never() that every member of an enum or a union is handled
- Reproduce the bugs that a wrong check or a stale narrowing lets through
Before you start
On this page
A payments team added a fourth status, REFUNDED, to an enum that their code branches on, and nothing failed: the
branches simply never ran for refunds. A type checker can find such a forgotten case, building on
narrowing: A type checker's working out, after a check such as if rank is None: return, that a value has a narrower type on the lines that follow: int instead of int | None. More in “Annotating variables, functions and collections”: it works out a more precise type inside a branch from the checks you
write. After if value is None: return, a value annotated str | None is a plain str on the next line, so
.upper() on it is safe.
TypeIs: The return type of a function that checks a value, as in def is_sort_order(text: str) -> TypeIs[SortOrder]. Where the function returns True the checker narrows the argument to that type, and where it returns False it removes that type (Python 3.13, PEP 742). (Python 3.13, PEP 742) lets you write your own check function that narrows the same
way. exhaustiveness checking: Making the type checker prove that every member of an enum or every type of a union is handled, by ending the branches with assert_never(), which only a value of type Never may reach. makes the checker prove that every case is
handled: put assert_never(x) in the last branch, and if any member of an enum or any type of a union can still
reach it, the checker reports the case you forgot.
Each check narrows a union inside its branch
"""How a checker narrows a union branch by branch; reveal_type() shows the type at each point."""
from typing import reveal_type
def describe(value: str | int | None) -> str:
if value is None:
return "nothing"
reveal_type(value)
if isinstance(value, int):
reveal_type(value)
return f"the number {value}"
reveal_type(value)
return f"the text {value.upper()}"
print(describe(None))
print(describe(7))
print(describe("asha")) Output
nothing the number 7 the text ASHA
Printed as an error (standard error)
Runtime type is 'int' Runtime type is 'int' Runtime type is 'str' Runtime type is 'str'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 describe.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
from session import run
# reveal_type() asks mypy for the type of value at three points of describe().
run("""
mypy --version
mypy --strict describe.py
""", folder="../branches") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict describe.py describe.py:8: note: Revealed type is "str | int" describe.py:10: note: Revealed type is "int" describe.py:12: note: Revealed type is "str" Success: no issues found in 1 source file
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_describe.py
reveal_type() asks the checker for the type of an expression at that point. mypy answered str | int after the
None branch returned, int inside the isinstance() branch, and str after it, which is why value.upper() passes.
Type narrowing needs no annotation of its own: the checker reads the conditions you already write. To repeat a check,
install the same checker with pip install mypy==2.4.0 and type the command after the dollar sign. At run time,
typing.reveal_type() (Python 3.11) prints the type of the actual value to standard error and returns it, so a call
left in the code does no harm, but it belongs to a debugging session and should not stay. Step through the function,
check by check:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The declared type. The parameter is annotated str | int | None, so at the top of describe() the checker allows only what all three types allow.
- None is handled. Line 6 tests value is None and returns from that branch. The checker reads the condition you already wrote; no annotation of its own is needed.
- What is left. After the branch that returned, None is gone, and reveal_type() showed str | int on line 8.
- isinstance() narrows again. Inside isinstance(value, int), mypy showed int on line 10, so the f-string with the number is checked against an int.
- Only str remains. That branch returns too, so on line 12 only str is left, and value.upper() passes the checker.
- The same at run time. The checks are ordinary Python, so the run took the same branches, and typing.reveal_type() printed the type of each real value to standard error, int for 7 and str for "asha".
Narrowing is what makes optional values practical. Without it, every use of a str | None would need a cast, and
every function that accepts a union would have to be split in two. With it, the usual Python checks are enough: a
guard clause that returns early, an isinstance() test, or a truth test such as if name:, which removes None
from the type in the true branch because None is always false.
Checkpoint
Check: Narrowing a union branch by branch
A TypeIs function narrows like a built-in check
Some checks are too specific for isinstance(). A sort order arrives as text from a query string, and the code must
turn it into the Literal["asc", "desc"] type that the rest of the program uses:
"""A user-defined check with TypeIs turns a plain str into a Literal type; a membership test does not."""
from typing import Literal, TypeIs, reveal_type
SortOrder = Literal["asc", "desc"]
def is_sort_order(text: str) -> TypeIs[SortOrder]:
return text in ("asc", "desc")
def order_from_query(text: str) -> SortOrder:
if text in ("asc", "desc"):
reveal_type(text)
if is_sort_order(text):
reveal_type(text)
return text
return "asc"
for raw in ["desc", "DESC", "random"]:
print(raw, "->", order_from_query(raw)) Output
desc -> desc DESC -> asc random -> asc
Printed as an error (standard error)
Runtime type is 'str' Runtime type is 'str'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sort_order.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
from session import run
# The membership test leaves a plain str; the TypeIs check narrows to the Literal type.
run("""
mypy --version
mypy --strict sort_order.py
""", folder="../predicates") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict sort_order.py sort_order.py:13: note: Revealed type is "str" sort_order.py:15: note: Revealed type is "Literal['asc'] | Literal['desc']" Success: no issues found in 1 source file
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_sort_order.py
The membership test text in ("asc", "desc") is true for the right strings, but mypy still showed a plain str
inside it, so returning text from there would be an error. is_sort_order() returns TypeIs[SortOrder], which
tells the checker what a True result proves, and inside that branch mypy showed Literal['asc'] | Literal['desc'].
A TypeIs function narrows both branches: when it returns False, the narrowed type is removed from the value’s type.
TypeGuard, the older form from Python 3.10, narrows only the branch where the check returned True. Name such
functions is_… and keep them small, because the checker believes their answer without looking inside them.
Checkpoint
Check: Your own checks with TypeIs
Exhaustiveness checking names the case you forgot
Here is the payments team’s function with the one line that would have caught the change. It handles the three
older members and ends with assert_never():
"""An exhaustive match over an enum: assert_never() in the last case, and a member nobody handled."""
from enum import Enum
from typing import assert_never
class Payment(Enum):
PENDING = "pending"
PAID = "paid"
FAILED = "failed"
REFUNDED = "refunded"
def label(payment: Payment) -> str:
match payment:
case Payment.PENDING:
return "Waiting for the bank"
case Payment.PAID:
return "Paid"
case Payment.FAILED:
return "Payment failed"
case _:
assert_never(payment)
for payment in Payment:
try:
print(payment.name, "->", label(payment))
except AssertionError as error:
print(payment.name, "-> AssertionError:", error) Output
PENDING -> Waiting for the bank PAID -> Paid FAILED -> Payment failed REFUNDED -> AssertionError: Expected code to be unreachable, but got: <Payment.REFUNDED: 'refunded'>
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 labels.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
from session import run
# REFUNDED reaches assert_never(), and mypy names it.
run("""
mypy --version
mypy --strict labels.py
""", folder="../payments") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict labels.py labels.py:22: error: Argument 1 to "assert_never" has incompatible type "Literal[Payment.REFUNDED]"; expected "Never" [arg-type] Found 1 error in 1 file (checked 1 source file) [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_labels.py
How a match narrows an enum until one member is left over
Text description of the diagram
The diagram is a flow from top to bottom.
- It starts with match payment, where payment can be any of the four members PENDING, PAID, FAILED and REFUNDED.
- After case Payment.PENDING, the checker knows the value is PAID, FAILED or REFUNDED.
- After case Payment.PAID, it is FAILED or REFUNDED.
- After case Payment.FAILED, only REFUNDED is left.
- The last case, case _ with assert_never(payment), expects the type Never, which has no values, but REFUNDED can still reach it.
- So the checker reports that line before the program runs, naming the member that no case handles.
Each case removes one member from the type of payment, so only REFUNDED can reach case _. assert_never()
accepts only Never: The type that has no values. assert_never(x) accepts only Never, so a checker reports the call wherever some member or type can still reach it., the type that has no values, and the checker reported the line
together with the member that reached it. At run time the same call raised AssertionError with the value in its
message, instead of returning nothing. Exhaustiveness checking works for unions of classes as well, and with chains of
if and elif that end in assert_never(). The check costs one line per branch and pays off every time the enum or
the union grows, because the checker then lists each place that has to change, before any test runs.
Key idea
End every branch on an enum or a union with assert_never(), and the checker names each member or type that
nothing handles, at the line that must handle it.
Checkpoint
Check: Exhaustiveness with assert_never()
What each check leaves in the other branch
Every check decides two branches, and not all of them say anything about the second:
| Check you write | Type inside the branch | Type in the other branch |
|---|---|---|
x is None |
None |
The rest of the union |
isinstance(x, int) |
int |
The rest of the union |
x is Payment.PAID on an enum |
Only that member | The other members |
A case of a match |
The matched class, member or literal | What is left, for the next case |
A function returning TypeIs[T] |
T |
The rest of the union |
A function returning TypeGuard[T] |
T |
Unchanged |
x in ("asc", "desc") on a plain str |
Still str in mypy 2.4.0 |
str |
Read the last column before you write an else branch: after a TypeGuard or a membership test it still holds the
whole type, so the code there needs a check of its own.
Checkpoint
Check: What each check leaves in the other branch
Check yourself
Practice that adapts to you
Practise this lesson
8 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
TypeIs and TypeGuard in depth: both branches, a lying check and narrowing that goes stale Pro: opens with a Pro, Premium or Ultimate pass
A check function is a promise that the checker believes without testing it. This part compares what TypeIs and TypeGuard promise in each branch, reproduces a check that lies and a narrowing that outlives the truth, and gives the rules that keep both safe.
In this part:
- Both branches compared, with the types mypy showed
- A check that lies, reproduced
- Narrowing that goes stale after a call, reproduced
6 runnable examples (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Exhaustiveness at scale: unions of records, the new-member drill and unreachable code Pro: opens with a Pro, Premium or Ultimate pass
Real programs branch on unions of records and on enums that grow. This part checks a union of dataclasses and a union of tagged dictionaries, runs the drill of adding one enum member to code that three functions share, and turns on the warning that finds code no input can reach.
In this part:
- Unions of dataclasses and of tagged TypedDicts
- The drill: one new member, three functions, and who notices
- Never, NoReturn and code that cannot run
- Exercise: parcel states
- A parcel state machine that a checker can prove complete
- Results of the sample tests
1 diagram · 8 runnable examples (Python) · 1 exercise with sample tests · about 10 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What is type narrowing, and which checks make a type checker narrow a type?
Type narrowing is the checker working out a more precise type for a variable inside a branch, from the check that led there. A parameter typed str | None is a str after if value is None: return, so .upper() passes. Checkers narrow after is None and is not None, isinstance() and issubclass(), comparisons with enum members and literal values, truth tests, the cases of a match statement, and calls to functions that return TypeIs or TypeGuard. The narrowing lasts until the variable is assigned again. reveal_type() shows the narrowed type at any point. Narrowing changes nothing at run time; it only lets the checker accept code that the checks already make safe.
-
Mid to senior
Python 3.13 added TypeIs although TypeGuard already existed. What problem does TypeIs solve, what rule does it add, and when would you still write a TypeGuard?
-
Fresher to senior
A new payment status was added, and for a week the refund job silently did nothing for payments in that status. How would you make the type checker catch this kind of change, and what can it still not catch?
-
Mid to senior
A teammate's is_valid_order(data) returns TypeIs[Order] but checks only two of the five keys an Order needs. What can go wrong, how would you demonstrate it, and how should check functions like this be written and tested?
Key takeaways
- Type narrowing turns a union into a precise type inside each branch, from the checks you already write, such as
is None,isinstance()and truth tests. reveal_type()shows the narrowed type, in the checker’s report and, throughtyping.reveal_type(), at run time.- A TypeIs function narrows both branches of your own check, and the checker trusts its answer without looking inside it.
assert_never()in the last branch makes the checker name every member or type that the code forgets.- Most checks decide the other branch too, but TypeGuard and a membership test on a plain
strleave it as it was.
Terms in this lesson
- exhaustiveness checking
- Making the type checker prove that every member of an enum or every type of a union is handled, by ending the branches with assert_never(), which only a value of type Never may reach.
- Never
- The type that has no values. assert_never(x) accepts only Never, so a checker reports the call wherever some member or type can still reach it.
- TypeIs
- The return type of a function that checks a value, as in def is_sort_order(text: str) -> TypeIs[SortOrder]. Where the function returns True the checker narrows the argument to that type, and where it returns False it removes that type (Python 3.13, PEP 742).
References
- typing: TypeIs (Python Software Foundation)
- typing: TypeGuard (Python Software Foundation)
- typing: assert_never (Python Software Foundation)
- typing: reveal_type (Python Software Foundation)
- Typing guides: type narrowing (The Python Typing Team)
- Typing guides: unreachable code and exhaustiveness checking (The Python Typing Team)
- Typing specification: type narrowing (The Python Typing Team)
- PEP 742: Narrowing types with TypeIs (Python Software Foundation)
- PEP 647: User-Defined Type Guards (Python Software Foundation)
- mypy documentation: type narrowing (The mypy project)
- mypy documentation: the mypy command line (--strict, --warn-unreachable) (The mypy project)
- mypy documentation: Literal types and Enums (exhaustiveness checking) (The mypy project)
- Pyright documentation: configuration (reportMatchNotExhaustive) (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress