typing icon indicating copy to clipboard operation
typing copied to clipboard

Request: an AssertingTypeGuard type for TypeGuard-like semantics

Open sirosen opened this issue 2 years ago • 5 comments

There is a frequent pattern of TypeGuard-like functions which assert or otherwise raise an exception if a type constraint is not met.

For example, https://github.com/microsoft/pyright/issues/2007 points to a case in which unittest provides assertIsNotNone, but a type-checker cannot infer that type narrowing has occurred. Arguably, the popular typeguard library is based around an implementation of "asserting" type guards. (One which deduces what assertions should be made from the annotations.)

TypeGuards allow for semantics like

y: str
assert is_list_of_str(x)
assert len(x) > 0
y = x[0]

An AssertingTypeGuard would allow for

y: str
assert_is_nonempty_list_of_str(x)  # note, this encodes another runtime check, the len check
y = x[0]

This becomes especially valuable if we consider that you might not want to do this all with assert. I may, as an author, prefer my own custom exceptions, e.g.

def assert_is_nonempty_list_of_str(x) -> AssertingTypeGuard[list[str]]:
    if not isinstance(x, list):
        raise ExpectedListError(x)
    if not x:
        raise EmptyContainerError(x)
    if not all(isinstance(y, str) for y in x):
        raise ContainedInvalidTypeError(x, str)
    return x

(Apologies if this repo is the wrong place to submit this request/idea. I'm happy to go through another process if necessary.)

sirosen avatar Nov 09 '21 22:11 sirosen

I like this idea, and have been mulling something similar myself. The name floating around my head was FailsUnless:

def assert_is_nonempty_list_of_str(x: object) -> FailsUnless[list[str]]:
    if not isinstance(x, list):
        raise ExpectedListError(x)
    if not x:
        raise EmptyContainerError(x)
    if not all(isinstance(y, str) for y in x):
        raise ContainedInvalidTypeError(x, str)
    return x

AlexWaygood avatar Nov 15 '21 08:11 AlexWaygood

pyanalyze provides a version of this, though there's no Python-level syntax for it yet. They're called "no_return_unless constraints". I have found it useful in two main contexts:

  • Assertion helpers like those from qcore.asserts, so that they provide narrowing like normal assert does.
  • Container mutators like list.append. In pyanalyze we type those as returning a constraint that sets the list that is being appended to to a new value, which helps with precise type inference for containers and makes it so pyanalyze generally doesn't need explicit types for them.

JelleZijlstra avatar Nov 15 '21 15:11 JelleZijlstra

Container mutators

That sounds incredibly unsafe, eg:

def foo(the_list: list[str]):
    the_list[0].upper()  # SUS ALERT

l1 = []
l2 = l1

l2.append(1)
l1.append("AMONGUS")

foo(l1)  # is l1 a list[str] or a list[str | int]

KotlinIsland avatar Nov 22 '21 04:11 KotlinIsland

They're called "no_return_unless constraints".

That seems consistent with typing.NoReturn. Would NoReturnGuard be a viable name? The idea is a mash-up of NoReturn and TypeGuard in some ways.

Container mutators

That sounds incredibly unsafe, eg: ...

This seems OT to me. TypeGuard and cast already let you do dangerous stuff. I have no opinion on the container-mutator case in particular, but I don't want to derail into discussing it.

I'm focused more on cases like

# some web application with a DB

def check_exists(db_object: Optional[DBModelBase]) -> NoReturnGuard[DBModelBase]:
    if db_object is None:
        raise HTTPNotFound(db_object)

maybe_user = get_user(user_id)
check_exists(maybe_user)
reveal_type(maybe_user)  # DBUser

sirosen avatar Nov 29 '21 18:11 sirosen

Something like the following gets nearly what you want (I'm currently using it in a project), but I admit it'd be a bit neater to have a real assert-like custom guard, which would also work with non-assignable variables and types which happen not to be classes.

class ParentClass:
	a: int


class ChildClass(ParentClass):
	b: str


T = typing.TypeVar('T', bound=ParentClass)


def assert_type(instance: ParentClass, expected_type: type[T]) -> T:
	if not isinstance(instance, expected_type):
		raise ValueError()
	return instance


variable: ParentClass = ParentClass()
variable = assert_type(variable, ChildClass)
print(variable.a)
print(variable.b)
print(variable.c)

variable = assert_type(variable, ParentClass)
print(variable.a)
print(variable.b)
print(variable.c)

Mypy 1.1.1 returns:

error: "ChildClass" has no attribute "c"  [attr-defined]
error: "ParentClass" has no attribute "b"  [attr-defined]
error: "ParentClass" has no attribute "c"  [attr-defined]

fwiesweg avatar Apr 25 '23 09:04 fwiesweg