Skip to content
← All Python articles

Python explainer

Type Hints for Code That Can Fail

About 4 min to read. Allow extra time to work through examples.

See what a type hint says about failure and what it leaves out, return expected failures as values, and keep context managers and decorators from hiding errors or types.

This article uses Python. You can make it your practice mode to browse related content across the site.

This function passes mypy --strict. So does every caller that forgets it can raise:

def parse_count(raw: str) -> int:
    return int(raw)  # raises ValueError for "abc"

A return type describes the value a function returns. It does not list the exceptions the function raises, and Python has no syntax for that. This article shows what you can do instead, and two places where a type hint decides how mypy treats an error.

Write the exceptions down, and test them

Name the exceptions callers should expect in the docstring, and say when each one happens:

def load_count(path: str) -> int:
    '''Read a count from the first line of a file.

    Raises:
        OSError: if the file cannot be read.
        ValueError: if the first line is not an integer.
    '''
    with open(path) as f:
        return int(f.readline())

Then write a test for each one, so the docstring stays true when the code changes.

Return an expected failure as a value

When failure is a normal outcome, such as bad input from a user, you can return it instead of raising it. The type checker then makes every caller that uses the result check which case it got. @dataclass writes __init__, __repr__ and __eq__ for you from a class's annotated fields, and frozen=True makes those fields read-only.

from dataclasses import dataclass


@dataclass(frozen=True)
class Parsed:
    value: int


@dataclass(frozen=True)
class Invalid:
    message: str


def parse_count(raw: str) -> Parsed | Invalid:
    try:
        return Parsed(int(raw))
    except ValueError:
        return Invalid("expected an integer")


def display_count(raw: str) -> str:
    result = parse_count(raw)
    if isinstance(result, Invalid):
        return result.message
    return str(result.value)


assert display_count("12") == "12"
assert display_count("unknown") == "expected an integer"

This is a Result type. The function returns either a success or a failure, and the caller checks which one it got. Read result.value before the isinstance check and mypy stops you:

error: Item "Invalid" of "Parsed | Invalid" has no attribute "value"  [union-attr]

mypy still lets a caller ignore the return value completely. A line that calls parse_count(raw) and keeps nothing loses the failure. And the function can still raise errors that it does not turn into an Invalid.

Check your understanding A function three calls down returns Parsed | Invalid. Does every function above it have to deal with Invalid? Show answer

Each one has to pass it on. A function that only forwards the result can return it as it is, with the same return type. A function that needs the number has to check with isinstance first. An exception needs none of this, because it travels up through the middle functions by itself. Use a Result when the caller handles the failure nearby, and an exception when a handler far up the call stack does.

Say whether a with block can swallow an error

If a context manager's __exit__ method returns True, the exception from the with block disappears. mypy reads the return type of __exit__ to see whether that can happen. With -> bool, it assumes the block may swallow an exception:

from types import TracebackType


class Quiet:
    def __enter__(self) -> None:
        return None

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> bool:
        return True


def to_int(raw: str) -> int:
    with Quiet():
        return int(raw)
$ mypy --strict quiet.py
quiet.py:17: error: Missing return statement  [return]

mypy is right. to_int("abc") returns None, because Quiet swallows the ValueError. If your manager never swallows exceptions, annotate __exit__ with -> None and return nothing. Keep -> bool for a manager that chooses to swallow some exceptions.

Keep a decorator from hiding types

A decorator typed with Callable[..., Any] hides the decorated function's types. The ... accepts any arguments, and Any switches off checks on the result. With this decorator, mypy accepts a call with both arguments wrong:

from collections.abc import Callable
from functools import wraps
from typing import Any


def logged(fn: Callable[..., Any]) -> Callable[..., Any]:
    @wraps(fn)
    def wrapped(*args: Any, **kwargs: Any) -> Any:
        print(f"calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapped


@logged
def label(count: int, *, prefix: str) -> str:
    return f"{prefix}: {count}"


label("3", prefix=1)  # wrong types, and mypy says nothing

Give the decorator type parameters instead:

from collections.abc import Callable
from functools import wraps


def logged[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapped

[**P, R] declares two type parameters, names that mypy fills in at each use. R stands for the decorated function's return type. **P is a ParamSpec, a type parameter that stands for the function's whole list of parameters, and P.args and P.kwargs pass that list through. Now mypy checks every call to label against its real signature and reports both wrong arguments. wraps copies the name and docstring of label onto wrapped. Neither wraps nor the type hints check types while the code runs.

The next exercise, Return Errors as Values with a Result Type, builds a reusable Result with Ok and Err classes.

step 19 of 20

Error Handling with Exceptions, Retries and Warnings

Up next Return Errors as Values with a Result Type