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:
typing.NoReturn: Introduced in Python 3.5 (PEP 484) to annotate functions that never return to their caller, such as functions that always raise an exception or terminate the process.typing.Never: Introduced in Python 3.11 (PEP 654) to provide an explicit, general-purpose bottom type. WhileNoReturnwas historically restricted to function return annotations,Nevercan be used anywhere a bottom type is conceptually needed.
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
- Use
typing.NoReturnfor function returns: If a function ends the program or unconditionally raises an exception, annotate its return type withNoReturn(orNever). - Use
typing.Neverfor values and exhaustiveness: When annotating variables, function parameters (such as custom assertion functions), or type arguments that should never occur, useNever. - Rely on
assert_never: Instead of manually raising exceptions in unhandled branches, useassert_never()to turn runtime omissions into compile-time/type-check errors.