Extend type hints to cover exceptions

There is a fifth possibility not mentioned which is to allow the type checker to infer what is raised from one function by seeing what other functions it calls. It is not necessary for code to either handle exceptions or declare that it raises exceptions because the checker can infer that. The checker should only check when a raises annotation is used explicitly. You don’t need to handle all error codes if you are do not make any claim about what is or is not raised.

This could look like:

# This is a function that might raise an
# exception. There is no explicit raise statement
# but we annotate the possibility of the exception
# explicitly so the checker knows that this
# possibility should be checked when it is asked.
def func_c(x: int) -> float:
    # raises ZeroDivisionError
    return 1/x

# This is a placeholder for many intermediate
# functions because our func_a1 etc functions do
# not call func_c directly. These functions do not
# need to have any explicit raises annotation
# because the checker can infer what is possibly
# raised from seeing what is called.
def func_b(x: int) -> float:
    return func_c(x)

# Checker accepts this because it correctly
# reports that ZeroDivisionError might be raised.
def func_a1(x: int):
    # raises ZeroDivisionError
    return func_b(x)

# Checker accepts this because ZeroDivisionError
# is handled.
def func_a2(x: int) -> float:
    # raises None
    try:
        return func_b(x)
    except ZeroDivisionError:
        return 0.0

# Checker accepts this but can infer that func_a3
# might raise ZeroDivisionError even if that is
# not explicitly stated.
def func_a3(x: int) -> float:
    return func_b(x)

# Checker rejects this. It claims not to raise
# anything but calls a function that raises and
# the exception is not handled anywhere
def func_a4(x: int):
    # raises None
    return func_b(x)

# Checker accepts this because we told it to
# ignore the possible exception. We believe that
# we know better than the checker that the
# exception would never be raised and so we
# explain that to the checker. It is possible that
# we might be wrong about that but if we are then
# that means there is a bug somewhere and it is
# acceptable to fallback on printing/logging the
# traceback at runtime. Something better than
# type: ignore could be used for this.
def func_a5(x: int):
    # raises None  : type: ignore
    if x == 0:
        return 0.0
    else:
        return func_b(x)

Importantly note that func_a3 is accepted but func_a4 is rejected by the checker. This is because the raises annotation is not required so func_a3 can happily just not mention the possibility of raising. The checker would only reject a raises annotation if an annotation is actually given. If not given then the checker just understands that the function possibly raises but does not need to require the programmer to state that explicitly.

In practice what this would mean in a large codebase is that there would be certain places where you know relevant exceptions can be raised and you annotate those explicitly. Then there would be certain other places where you want the exceptions to be both documented and checked (e.g. public API for a library). In between most functions would not need to declare that they do or do not raise anything although there might be a few places that do the analogue of func_a5 or func_a2 which can wrap a function that raises to make a function that does not raise.

Of course in the event of bugs there might be other cases where exceptions are raised that differ from what is claimed by the raises annotation. Those can clearly be understood as bugs though and in those cases the best course of action much of the time is just to allow the exception to propagate to top-level and be handled by the logger or print the traceback etc.

1 Like