Statically tracked exceptions in Python's type system to support EAFP

Python has historically considered the EAFP pattern to be more idiomatic, where a program might perform an unsafe operation, catching and handling the resulting exception:

client: User | Client | None
try:
    name = client.name
except AttributeError:
    name = None

However, this philosophy is at odds with modern static type checking, which often push developers towards the LBYL pattern, where an explicit check is performed first. Type checkers will flag the potentially unsafe attribute access as a type mismatch, despite that the code handles the resulting error correctly. Therefore, modern type checkers encourage something like:

client: User | Visitor | None
if isinstance(obj, User):
    name = client.name
else:
    name = None

This, however, now goes against Python’s philosophy of duck typing. So, you might define a protocol:

@runtime_checkable
class Named(Protocol):
    name: str

client: User | Visitor | None
if isinstance(client, Named):
    name = client.name
else:
    name = None

(Note that neither Pyright nor Mypy narrows types on a call to getattr().) This is more verbose than both the above examples, requiring a protocol definition, while providing no meaningful advantage over the EAFP version presented above in terms of readability or static type safety. The current solution is to of course use cast() or Any, but type checkers cannot verify that the potentially type-unsafe operation is handled, which defeats one of the main goals of static type checking.

Therefore, I would propose that Python add a feature to allow exceptions to be declared in function signatures in order to better support EAFP.

Note that I am not proposing that Python add a comprehensive checked exception system similar to Java, this proposal would be much more limited in scope and primarily used to support EAFP. It is not recommended that any or even most functions that may raise an error are annotated as such, in fact, I would argue that no current signature in typeshed as of writing this should retroactively be given such a signature, to preserve backwards compatibility and also because it would be silly for <float> / <float> to return float | Raises[ZeroDivisionError], no one wants that.

A function’s return type would be able to be annotated as raised Raises[E] (indicating that it raises an exception unconditionally) or a union including Raises (with the rest of the union indicating the type of value returned on success), where E is a subclass of BaseException. For example:

# Code is an illustrative example only, more practical examples follow
def get_value() -> int | Raises[ValueError]:
    if random.random() < 0.5:
        return 0
    else:
        raise ValueError

At the value level, Raises would be considered a bottom type, just like Never:

reveal_type(get_value())  # int

However, unlike Never, a Raises type would carry an additional effect, that an exception might have been raised. Therefore, while a type checker may simplify int | Raises[ValueError] to int at the value level, it would have to keep track of the possible exception separately.

Type checkers would permit any of the following in order to handle the error:

The motivation for one of the following explicit patterns to be required is that otherwise, for example, a simple typo in an attribute name would result in a type checking error far away from the typo or perhaps even not at all. It would be preferable for the type checker to raise a type checking error at the site of the misspelled attribute, just as it does now, unless one of the following patterns that are evidence of purposeful EAFP is used, rather than allowing typos or mistakes to pass silently. Note that importantly, the type checker would report an error at the call site, not the function signature.

  • Catching the exception
try:
    value = get_value()
except ValueError:
    value = None
reveal_type(value)  # int | None
  • Propagating the exception by declaring it in the calling function’s return type
def myfun() -> str | Raises[ValueError]:
    return str(get_value())
  • Casting the return value of the function to one that does not raise
value = cast(int, get_value())
  • Explicitly propagating the exception unchecked (which maintains type safety on the successful path, unlike the cast above)
def myfun() -> int:
    try:
        return get_value()
    except ValueError:
        raise

If none of these patterns are used to handle the possible exception, the type checker would emit a type checking error. Also note that failing to declaring an exception that is raised except for one that results from a function returning Raises or a union including it is not a type checking error in any way.

Possible applications for this feature include (all of the following would be used to better support EAFP, and none of these replace an existing signature in typeshed):

  • Possibly unsafe attribute access
class object:
    def __getattr__(self, name: str) -> Raises[AttributeError]: ...
  • Checking if an object is iterable using iter() (note that isinstance(..., Iterable) can fail for objects that use the sequence iteration protocol)
@overload
def iter[T](iterable: Iterable[T], /) -> Iterator[T]: ...
@overload
def iter[T](obj: object, /) -> Raises[TypeError]: ...
  • Accessing a key in a dictionary with a broader type than the declared key type (this signature would also be applied to .get(), which also only accepts a key of the right type. On a separate note, .get() with a default should really not have this restriction.)
@overload
class dict[K, V]:
    @overload
    def __getitem__(self, key: K) -> V: ...
    @overload
    def __getitem__(self, key: object) -> Raises[KeyError]: ...

As an example of how the typing would work in practice, say that User has a declared attribute name of type str, while Visitor and None have no such attribute. Then, getattr(<User | Visitor | None>) would return str | Raises[AttributeError], which simplifies to str on the successful path.

I’m currently unsure whether such a call should narrow the union to User on the successful path, and what an annotation of just Raises should narrow to on the successful path (perhaps Never, but is it desirable for type checkers to assume the following code is unreachable? Any may be more appropriate while sacrificing some type safety.)

… but…

… so you are asking for a checked exception system.

No.

Exceptions should be caught where you can handle them. The entire point of exceptions (rather than error returns) is that you should not catch them just because they can be raised. Having a type checker flag something because you failed to catch or explicitly propagate an exception defeats the purpose of exceptions.

This has been repeatedly rejected for good reasons. Do you have answers to those reasons?

Did you actually read the motivation behind this proposal at all? If you weren’t required to handle accessing an unknown attribute on a type and object.__getattr__ just returned Any or something, for example, obj.nmae would pass type checking totally silently, and catching silly typos in attribute names is one of the main things that type checkers are good at. It should still be a type checking error, unless you surround it in a try-except block that catches AttributeError (or cast or propagate it, but there should be evidence it wasn’t just a typo).

And the entire point of this proposal is to allow users of type checkers to do things that type checkers currently don’t allow them to and have them be understood while not sacrificing type safety. In other words, giving them more freedom rather than restricting their freedom.

There have been earlier proposals of checked exceptions for the type system, but none of them focused primarily on EAFP. My illustrative example of declaring ValueError just because I raised it is not the intended use. I think I mentioned that if it’s not being used to support EAFP, it shouldn’t be declared.

I did, but I mainly read the proposal itself, because whatever the motivation is, if the proposal is one that’s been rejected before, you’ll need to be able to answer the prior objections.

I might have a motivation of “bring about world peace”, but if the proposal is “introduce variable declarations to Python” then it’s still unlikely to succeed, no matter how much people might like the goal. (Though, now I think about it, any proposal that could lead from variable declarations to world peace would at least be an entertaining one.)

That’ll need to be made clear in the proposal somehow, then. Right now, it’s still just another proposal for checked exceptions, with all the same issues that that has, and needs to answer them.

1 Like

I get focusing on the proposal and not the motivation, but I answered those questions (the scope, which is primarily to support EAFP, why it’s giving more freedom to users of type checkers, not less, and why requiring explicit handling is desirable) as part of explaining the motivation.

I have reread it, and aside from most of it being (as motivations usually are) largely matters of opinion that can be agreed with or disagreed with, it doesn’t answer the question of how this isn’t checked exceptions.

It is fundamentally still a checked exception system, and I never claimed it wasn’t (I only claimed that it wouldn’t end up looking like Java).

Sorry for the code blocks below, I can’t get them working right on mobile where I’m posting this from.

I think the largest sticking point I’m hearing is the propagation rule when we think about checked exceptions and in this case I’m not sure that it’s actually needed for EAFP.

If we go back to:

```py

client: User | Visitor | None

try:

name = client.name

except AttributeError:

name = None

```

The problem, as I read it, is a false positive error we can address differently. Basically, if a type checker flags `client.name` even though the failure is handled right there, I think that can be fixed with a success-path typing rule similar to how checkers already do for `isinstance()`:

> An attribute access that would be an error only because some members of the union lack the attribute is not reported when it appears inside the body of a `try` statement that has a handler explicitly naming `AttributeError`. The resulting type is the attribute type from the members that have it.

So `name` is `str` inside the `try` and `str | None` after it.

If that does work / can work as I think it does, that should mean we can still deliver:

- EAFP code type checks without a protocol, `cast()`, or `Any`.

- Typos are still caught because something like `client.nmae` would still error because no member of the union has `nmae`.

- `except Exception:` and bare `except:` shouldn’t narrow, so using catch-alls doesn’t inadvertently switch the check off.

I believe that separates it from checked exceptions enough to avoid many of the arguments against it. You catch the exception where you can handle it, and the checker takes your word for it. If you want a helper to do the access and have its caller do the catching, you’re back to declaring exceptions in signatures, and we’re back to checked exceptions, so I’d leave that aspect out of scope

This should also allow for using the same approach to extend to `KeyError` on subscripts with a broader key type, and maybe others. Since this only relaxes existing errors, it may not need a typing spec change at all and could start as a feature request to ty, mypy and pyright.

Obviously this is preferable, and even more preferable would be narrowing on hasattr. Mainstream type checkers have turned down suggestions to implement these, though.

And this doesn’t really address the iter() issue. Trying iter() is the only way to test conclusively whether an object is iterable, since isinstance(… Iterable) doesn’t work for the sequence iteration protocol. So you’re stuck between choosing the imprecise but type checking compliant isinstance(…, Iterable) or doing it the right way and silencing the type checker.

The takeaway here is that whether iterating over an object is safe depends solely on whether an exception was raised or not, but Python’s type system doesn’t have a way of expressing that.

I think though there is a much better chance of making a case for this approach than the reverse.

Wouldn’t iter be another success path rule against an exception such as TypeError?

Of course, you can add this to the list of exceptions in an exception-aware type checker at all. And if you keep doing this (say, you add len() raising TypeError to the list of exceptions as well) you end up with exactly this proposal, where certain operations are allowed if an exception is caught, but it’s all handled implicitly by the type checker and is not actually written anywhere in typeshed.

I tried to offer an alternative approach that would allow special-casing for your needs. I don’t agree that we ultimately arrive at the same place, but I respect your opinion and will bow out of the discussion here.