Yeah, this is basically what we do. It ends up a bit more ergonomic to not have explicit result types because union types are pretty easy in Python. For example, we have functions like
# courses.py
def create(...) -> Course | ErrInvalid:
and they get used like
match courses.create(...):
case courses.Course() as course:
# We use `course` below.
pass
case courses.ErrInvalid() as err:
# Error conditions are usually translated into early returns
raise fastapi.HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, err.message
)
return APICourseCreateResponse(id=course.id, ...)
It has worked (for us at least) pretty well to reduce the number of unhandled errors that make their way into Sentry. The granularity of the error types depends on the need (e.g., sometimes we just have an `ErrInvalid` that returns a message which gets displayed to the user, but sometimes we'll have ErrInvalidAddress or ErrInvalidTaxCode to be more specific for the context).
And yeah, without nodiscard, we try™ to only do this on functions that return values that the caller will definitely need to use. Also combined with exhaustiveness checks on switch statements by default.