typing.Never and NoReturn for Unreachable Code in Python

Python's typing system provides specialized constructs to represent computations that do not terminate normally or values that can never exist. This article explains how typing.NoReturn and typing.Never function, the difference between them, and how static type checkers use them to detect dead code and enforce exhaustive pattern matching.

Understanding the Concepts: Bottom Types in Python

In type theory, a "bottom type" is a type that has no values. If a variable has a bottom type, it indicates a logical impossibility during runtime.

Python implements this concept primarily through two annotations:

To modern static type checkers like Mypy and Pyright, Never and NoReturn are treated as equivalent.

Indicating Functions That Never Return

When a function always raises an exception or halts program execution, it will never yield control back to the call site. Annotating such a function with NoReturn or Never informs the type checker that any code directly following the call is unreachable.

import sys
from typing import NoReturn

def terminate_execution(message: str) -> NoReturn:
    print(f"Fatal error: {message}", file=sys.stderr)
    sys.exit(1)

def process_data(data: dict | None) -> None:
    if data is None:
        terminate_execution("Data cannot be None.")
    
    # The type checker knows `data` cannot be None here.
    # It also knows this line is unreachable if data was None.
    print(data.keys())

Because terminate_execution is typed with NoReturn, the static type checker automatically narrows data from dict | None to dict on the subsequent lines.

Exhaustive Type Narrowing and Dead Code Detection

The most powerful use of typing.Never is verifying that conditional branching covers every possible case in a Union or Enum. Through type narrowing (using if/elif/else or match/case), the type checker eliminates possibilities branch by branch.

If all possibilities are handled, the type of the variable in the final fallback branch is narrowed to Never. If a new case is added to the data structure in the future, the variable in the fallback branch will no longer be Never, causing the type checker to flag an error.

Python 3.11 introduced typing.assert_never to standardize this pattern:

from typing import Literal, assert_never

Status = Literal["pending", "approved", "rejected"]

def handle_status(status: Status) -> str:
    if status == "pending":
        return "Waiting for review."
    elif status == "approved":
        return "Request granted."
    elif status == "rejected":
        return "Request denied."
    else:
        # If all cases are handled, `status` has type `Never`.
        # If a new status is added to `Status` and not handled above,
        # the type checker flags a type mismatch error here.
        assert_never(status)

At runtime, if unexpected data reaches assert_never, it raises an AssertionError. Statically, it guarantees that dead code paths remain truly unreachable.

Key Differences and Best Practices