Extend type hints to cover exceptions

I understand the arguments of both sides.
I would like to see the ability to indicate a recommendation to handle certain exceptions.

As most simple I perceive a new keyword like „propagate“ that I can use to indicate that a certain condition might raise an exception that the caller should handle. A typical use case would be a connection error on a http request. So I could write sth like

def f():
try:
request_data()
except ConnectionError as ex:
propagate ex

So that a type checker can give me a hint to handle ConnectionError when I use f()

How would the type checker know who should handle the exception? It’s not necessarily the caller.

How is this different from just raise?

2 Likes

With apology on reviving a pretty-dead thread, I would like to note a completely different use case for exception annotations.

Specifically, I may want to specify an interface with a function signature that indicates which exceptions will be thrown under certain edge cases. As a basic example, with the proposed syntax:

class MyObjectHandler(ABC):
    ....
    @abstractmethod
    def get_by_id(id_: str) -> None ^ NoSuchObjectException:
        ...

A type checker will not check that you handle or raise all exceptions that might be raised in your code, or even that you explicitly hint all exceptions that you raise; instead, it may check only that you do raise those exceptions that you explicitly hinted.

This is very useful for two reasons:

One, that when you implement MyObjectInterface, you’ll know to raise the proper exception if a user tried to get a nonexisting object. Today this info will sit in the docstring of the method in the base class, but this is much less concise and much easier to miss — IDEs will generate the function signature in implementation stub, but not the docstring.

That Interface implementations handle edge cases in a consistent manner is extremely important, because the functions that use these interfaces must be able to handle them themselves. The code that uses MyObjectHandler wants to know when the get operation fails for this reason. Will it return None? Will it raise an exception? If so, which type? Being able to look at the type hinting both at implementation and at use has an immense benefit.

Two, at runtime, frameworks that generate UI out of pythonic functions, like fastapi and typer, will be able to automatically generate documentation for error responses.

As an example, fastapi could provide some way to specify the status code of exception types, and then if it knows that an api function raises NoSuchObjectException, the generated openapi.json could detail a possible response of 404 if the requested object does not exist. This would save a great amount of time writing down your possible responses by hand, which is what you have to do currently — as fastapisimply has no way to know otherwise.

2 Likes

I think that wording is an issue. Throughout this thread, I’ve seen a lot of talk about checked exceptions. But, the word checked is problematic as that’s not what hints are about (note that this is the last use of the word “checked”). They are hints that can be ignored in some of my codebases. Just like I choose to not ignore type hints in some codebases, it would be nice to not ignore exception hints in some codebases too.

Custom forms of Exception Hints are already possible via Annotations.

import typing

Exceptions = typing.Union

class MyNonFatalException1(Exception): ...
class MyNonFatalException2(Exception): ...

def foo() -> typing.Annotated[int, Exceptions[MyNonFatalException1, MyNonFatalException2]]:
    ...

With this, I can continue to ignore exception hints just as I already ignore exception hints written out in docstrings.
However, this is just one custom way of doing it. You could write this in many other ways. It would be nice to have standardized syntactic sugar for hinting exceptions so I can ignore them in a standardized way.

2 Likes

Ignoring them in a standardized way is already possible, just don’t write them (or write them as comments) :slight_smile: The hard part is when you want to NOT ignore them, and you’re trying to do that in a standardized way. Because at that point, we’re right back to the question of “what does this annotation even mean?”. It’s easy to say “let’s have an annotation about exceptions” and much much harder to find a single semantic meaning for that annotation that everyone can agree on.

When I write a function like this:

def spam(msg: str, when: int | datetime.datetime): ...

we can all agree that this function is supposed to receive a string as its first parameter, and an integer or datetime as its second. That is well-defined semantics. So what are the semantics for this exception annotation? Does the function have to strictly include the statement raise MyNonFatalException1 in it? What if it calls a different function that raises that? Should it be annotated or not? People do not all agree on this which is why we still haven’t gotten anywhere.

When you are NOT ignoring these annotations, how do you interpret them?

1 Like

What exactly are the implications of this form of hint for a tool like mypy?

1 Like

Two more links for completeness:

1 Like

I really like the idea of a exception type check, and I think this can still be done in a pythonic way.

I think for the most part their is merit in just ignoring the type of exceptions entirely, then in an except block, the ide can list all the expected errors that might happen in the try block, and then the developer can either handle one or more of the errors as shown by the ide, or handle whatever error they want to. This will narrow down for the developer what errors they could expect, and reduce the overhead of reading documentation.

Often there are exception that can explicitly be handled, but go unnoticed, either because of poor documentation or because of it’s obscurity in development situation or even because you forgot.

Then the unhandled errors can just go up the chain. So you can just write however you want, but when handling error you’ll get a little extra help.

Also for things like __next__ and contextlib’s contextmanager, it can help a little, by giving you a consice description of what error you should raise.

It’s almost always easier for me to code if I don’t need to read documentation to do what I want to do and if the ide can give me appropriate suggestions depending on context, but not restrict me too much in terms of how I write my code. Which is why I like python so much. And this can help without really adding much in terms of restriction.

As I understand, this wouldn’t be the traditional checked exception, but a very loose variant of the same.

One last note, which isn’t as important, I often just write down a bunch of code and document it only after I’m done writing all of it, so having an extra help on handling error that was thrown in another file, can very much help me.

2 Likes

I think it should be more like the original proposal (or something similar), because a raise is NOT a return, so disguising it as one definitely will introduce hassle.

I think that Java gave checked exceptions a bad name. They had a new toy and they got carried away and made things like IOErrors checked, which is a real pain - what are you going to do about it?

However if done well and importantly optionally, the feature is very handy. I notice that language designers are coming back round to this way of thinking, e.g. Swift has added them (swift-evolution/proposals/0413-typed-throws.md at main · apple/swift-evolution · GitHub).

Their syntax is:

func callCat() throws(CatError) -> Cat {

In Python something similar might be:

def callCat() raise(CatError) -> Cat:

Or perhaps with a decorator to save any new syntax.

@raises(CatError) 
def callCat() -> Cat:
5 Likes

I actually found this really helpful as a flag for code that does I/O. I wouldn’t want it added to Python just for that, of course! But interestingly, a lot of the pain points with checked exceptions like IOException are also there with asyncio – you can’t easily add in I/O to code that didn’t do it before, and that makes some things like controlled rollout of new code paths separately from release cycles harder.

3 Likes