Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 15 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: Each check narrows a union inside its branch · A TypeIs function narrows like a built-in check · Exhaustiveness checking names the case you forgot · 2 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

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

One value, three types, narrowed branch by branch Python · branches/describe.py
"""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

mypy on describe.py Python · check/check_describe.py
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:

Interactive · stage by stage How describe() narrows str | int | None Step through describe() from top to bottom. Each stage is a point in the function, and its sub-label is the type mypy gave value there.
How describe() narrows str | int | None, step 6 of 6. Showing describe(value), if value is None:, line 8, after that check (shown: str | int), isinstance(value, int) (shown on line 10: int), line 12, after that branch (shown: str, upper() ok). In focus: if value is None:, isinstance(value, int), line 12, after that branch.describe(value)str | int | Noneif value is None:returns "nothing"line 8, after that checkshown: str | intisinstance(value, int)shown on line 10: intline 12, after that branchshown: str, upper() ok

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. What is left. After the branch that returned, None is gone, and reveal_type() showed str | int on line 8.
  4. 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.
  5. Only str remains. That branch returns too, so on line 12 only str is left, and value.upper() passes the checker.
  6. 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

  1. Question 1 of 2 In describe(value: str | int | None), what type did mypy reveal on line 8, after the branch for None returned?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: str | int

    Each check that ends its branch with a return removes a type from the rest of the function: None after line 6, int after the isinstance() branch, until only str is left.

  2. Question 2 of 2 def greet(name: str | None) -> str: begins with if name: and calls name.upper() inside that branch. Why does the checker accept the call?

    Choose one answer.

    A hint

    Think about what None is in a truth test.

    Show the answer: Question 2 of 2

    Answer: A truth test removes None in the true branch, because None is always false

    The usual Python checks are enough for narrowing: a guard clause that returns early, an isinstance() test, or a truth test such as if name:, which removes None in the true 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 TypeIs check turns a str into a Literal type Python · predicates/sort_order.py
"""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

mypy on sort_order.py Python · check/check_sort_order.py
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

  1. Question 1 of 2 text is a str. Inside if text in ("asc", "desc"):, what type did mypy 2.4.0 show for text?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: str, unchanged

    Returning text from inside the membership test would be an error, because to mypy it is still a str. A function returning TypeIs[SortOrder] gives the checker the narrowing the test itself does not.

  2. Question 2 of 2 Why should a TypeIs function be small and named is_…?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The checker believes its answer without looking inside it, so it must be easy to read and to test

    TypeIs tells the checker what a True result proves, and the checker takes it on trust. Keep such functions small, name them for what they test, and give them tests of their own.

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():

A match over an enum, with one member nobody handled Python · payments/labels.py
"""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

mypy on labels.py Python · check/check_labels.py
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

Each case of the match removes one Payment member from the type, until assert_never() receives REFUNDED.match paymentPENDING | PAID | FAILED | REFUNDEDafter case Payment.PENDINGPAID | FAILED | REFUNDEDafter case Payment.PAIDFAILED | REFUNDEDafter case Payment.FAILEDREFUNDEDcase _: assert_never(payment)expects Never, gets REFUNDEDthe checker reports this linebefore the program runsPENDING handledPAID handledFAILED handlednothing handles REFUNDED

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()

  1. Question 1 of 2 Which member did mypy name at assert_never(payment) on line 22 of labels.py?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: REFUNDED, the member that no case handles

    assert_never() accepts only Never, the type that has no values. The member that still reaches it is the case the code forgot, and the checker names it with the line to change.

  2. Question 2 of 2 What happened at run time when REFUNDED reached case _: assert_never(payment)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: assert_never() raised AssertionError with the value in its message, instead of returning nothing

    The same line protects both sides: the checker reports the forgotten member before the program runs, and the program stops with the value in the message if one gets through anyway.

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

  1. Question 1 of 2 A check function returns TypeGuard[int]. What is the value's type in the branch where it returned False?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Unchanged, the declared type

    TypeGuard, the older form from Python 3.10, narrows only the branch where the check returned True; TypeIs narrows both.

  2. Question 2 of 2 payment is a Payment with four members. What does the other branch of if payment is Payment.PAID: hold?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The other three members

    Comparisons with enum members and literal values, like is None and isinstance(), decide the type in both branches; only TypeGuard and a membership test on a plain str leave the other branch as it was.

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.

  1. Question 1 of 8 What type does the checker give value on the last line?

    Read the code, then choose one answer.

    def size(value: list[int] | None) -> int:
        if value is None:
            return 0
        return len(value)
    Show the answer: Question 1 of 8

    Answer: list[int]

    The branch that handles None returns, so on the last line only list[int] is left. That is why len(value) passes the checker without an error about None.

  2. Question 2 of 8 After if value is None: return, the code assigns value = lookup(), where lookup() returns str | None. What is the type of value on the next line?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: str | None, because the new assignment ends the narrowing

    Narrowing is about one value at one point in the code. Once the variable is assigned again, the checker uses the type of the new value until the next check.

  3. Question 3 of 8 text is a str. After if text in ("asc", "desc"):, what type does mypy 2.4.0 give text inside the branch?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: str, unchanged

    The recorded run of sort_order.py showed str there: a membership test does not turn a plain str into a Literal type for mypy. A function returning TypeIs[SortOrder] does, as the second type it showed proves.

  4. Question 4 of 8 is_int(value) returns TypeIs[int] and value is int | str. What is the type of value in the else branch?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: str

    TypeIs narrows both branches: int where the check returned True, and the rest of the union, str, where it returned False. TypeGuard would leave int | str in the else branch.

  5. Question 5 of 8 What does labels.py print?

    What does this program print? Choose one answer.

    """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)
    Show the answer: Question 5 of 8

    Answer: it prints

    PENDING -> Waiting for the bank
    PAID -> Paid
    FAILED -> Payment failed
    REFUNDED -> AssertionError: Expected code to be unreachable, but got: <Payment.REFUNDED: 'refunded'>

    No case matches REFUNDED, so it reaches case _, where assert_never() raises AssertionError with the value in the message. The checker reported the same line before the program ran.

  6. Question 6 of 8 Why put assert_never(x) in the last branch instead of leaving the branch out?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: The checker then reports every member or type that can still reach that branch

    assert_never() accepts only Never, the type with no values. If any case is missing, a member still reaches the call and the checker reports it; at run time the call raises AssertionError instead of going on silently.

  7. Question 7 of 8 value is int | str | None. What is its type in the other branch of if isinstance(value, int):?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: str | None

    is None, isinstance(), comparisons with enum members and literal values, match cases and TypeIs all decide the other branch too; TypeGuard and a membership test on a plain str do not.

  8. Question 8 of 8 A function receives status: str from a query string and must hand on a Literal["open", "closed"], keeping a plain str in the other branch for an error message. Which check gives mypy 2.4.0 both?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: A function is_status(text) -> TypeIs[Literal["open", "closed"]]

    The table of checks answers it: a membership test leaves a plain str for mypy, while a TypeIs function narrows the True branch to its type and leaves the rest in the other.

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.

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

  1. 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.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

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, through typing.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 str leave 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.