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 thatisinstance(..., 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.)