Report unnecessary (always True) assertions

Are there any type checkers that support reporting of unnecessary assertions?

E.g. if there was test_call that used to return possible None value but None return value was deprecated and it returns just object, so all assertions that were used for typing became unnecessary as they always return True.

# def test_call() -> object | None: ...
def test_call() -> object: ...


def main() -> None:
    res = test_call()
    # Report as unnecessary assertions as they always true?
    assert isinstance(res, object)
    assert res is not None

object | None is just object (None is an object). isinstance(res, object) will always return True regardless of what res is, while res is not None has ambiguous truthiness.

Pyright should be able to report the first when used as an if condition (not in an assertion), but for some reason it does not collapse object | None into object:

(playground)

def f() -> object | None: ...
def g() -> object: ...
def _() -> None:
    a = f()
    b = g()

    reveal_type(a)             # object | None
    reveal_type(b)             # object
    
    if isinstance(a, object):  # no error
        reveal_type(a)         # object
    if isinstance(b, object):  # error: Unnecessary isinstance call
        reveal_type(b)         # object
    
    if a is not None:          # no error
        reveal_type(a)         # object
    if b is not None:          # no error
        reveal_type(b)         # object
1 Like

You’re absoulte right, it wasn’t a perfect example.

Here’s a better example. With Pyright indeed it seems to work if strict is enabled and only for if statements but not for assert. pyright-play

# pyright: strict


class A: ...


# def test_call() -> A | None: ...
def test_call() -> A: ...


def main() -> None:
    res = test_call()
    # Report as unnecessary assertions as they always true?
    assert isinstance(res, A)
    assert res is not None

    # Unnecessary isinstance call; "A" is always an instance of "A"
    if isinstance(res, A):
        pass

    # Condition will always evaluate to True since
    # the types "A" and "None" have no overlap
    if res is not None:
        pass

In the general case, if something is being asserted then it should always be true. If it’s not then that’s a bug, especially as the assertions are removed when running with -O.

Even in the case where the assertion is checking a type narrowing expression that doesn’t actually narrow the type I don’t think this should be reported for assertions.

If the intention of the assert statement is to confirm that something that should be true is actually true when developing/debugging, then the type narrowing type checkers do is a side effect. (It may be why you might add such an assertion, but it’s not the only function the assert statement performs). The type checker can’t know if it’s there purely for type narrowing or if it’s left there as a sanity check and so probably shouldn’t consider it an error.

5 Likes

You may want to propose this to the Ruff type checker or linter. Assertions that are statically true can be made to demand a # noqa: RUF... to indicate that you’re sure you want the assertion.

Red Knot does checks for such conditions, partly via its static-assert-failure rule. It doesn’t understand native asserts yet, though.

2 Likes

Yes, it should always be true when code is actually executed but it’s not the same as some statement being statically true.

There could be checks like assert isintance(arg, A) in def func(arg: A) if for some reason we really want to be sure that arg is A at runtime. But if we’re talking about the strict typing, in theory, other types besides A should never be passed as arg, so there should be no need to check something that is statically always True and assert is unnecessary in this case.

I don’t know how common this use of assert is by but typically I use assert when I know from some other external conditions (type system isn’t perfect) that for example it’s impossible that some arg type will be None.
assert is short, doesn’t create nesting, doesn’t require additional handling if it fails and it prevents code from going in the wrong direction if bug occurred.

static_assert seems very different, not sure what are the usecases for it but it doesn’t work with isinstance.

from knot_extensions import static_assert
from typing import assert_type

def f(a: str):
    static_assert(1 + 1 == 2)
    static_assert(None is None)
    static_assert(a is not None)
    # Static assertion error: argument of type `bool` has an ambiguous static truthiness
    static_assert(isinstance(a, str))
1 Like

[…] not sure what are the usecases for it but it doesn’t work with isinstance .

Not yet, no.

The point of the function is to make Red Knot check the truthiness of the given argument, commonly a condition.


As of today, len(), for example, is special-cased:

(playground)

from knot_extensions import static_assert

def f(a: tuple[int, int, str]):
    static_assert(len(a) == 2)  # error: argument evaluates to `False`

isinstance() will soon follow, probably.

1 Like

Very cool. We’re all watching Red Knot with excitement!

If you need to guarantee that arg is A at runtime you should not be using an assert as assert statements are removed at runtime under -O.

As you say later, they are to be used when you know it’s not possible for the assert not to be True, because at runtime there is no guarantee the assert will even be there. As such they are not there[1] to prevent things from going wrong if a bug occurs.

I guess the crux of the issue is that technically all asserts should be unnecessary, they are there for development and debugging and I don’t think that because a type checker also believes the expression to be True it should tell you to remove it.


  1. Quite literally not there in the case of -O ↩︎

I think you’re missing the point of the issue. All asserts check things that are supposed to be true.

However, some asserts are statically true, and some are statically undecided. Statically true assertions are more likely to have been added or left in code by mistake.

For example

def f(x: float | None) -> None:
  if x is None:
    x = 1.0
  assert x is not None  # Possibly added by mistake for the benefit of some type checker.
  ...

Such assertions can be added to help static type checkers narrow types. I agree that type checkers should probably not warn on extraneous asserts by default, but having an option to warn can help remove unnecessary assertions.

On the other hand, sometimes an interface has many untyped clients, in which case you may want such assertions:

def f(x: float ) -> None:
  assert x is not None  # Added to mitigate calling bugs and provide early warning.
  ...

These two situations are indistinguishable except by a comment, e.g., # noqa: RUF... for some new error code.

1 Like

If the goal is purely to correct a static type checker, then typing.cast should be used and not assert. A cast call that isn’t performing any narrowing is already raised as an error by mypy and pyright in strict mode.

Yes that’s part what I was trying to get at, other than pushing back on any suggestion that an assert provides a runtime guarantee.

The type checker knows that it’s not doing any narrowing based on the assert, but it can’t necessarily distinguish between an assert that’s only there to perform type narrowing for a type checker and one that’s there as a debug check and as such it shouldn’t be telling you to remove all of them.

If a static tool does want to add support for erroring on assertions it believes are only there for type narrowing it should not be something that is enabled by default[1]. I don’t think it’s good if an update to a tool suddenly starts highlighting valid asserts and potentially leads to people removing the assert to avoid having to put # noqa comments everywhere.


  1. or, I’d argue in any ‘standard’ strict mode ↩︎

cast is just unsafe as it’s discarding available typing information completely.

# pyright: strict
from typing import cast

def test(a: str):
    b = cast(int, a)
    # No errors.
    print(b + 25)

Regarding runtime guarantees - yes, there is -O that disables asserts but there are cases when it’s not used, and type checkers already taken position where they do accept assert as being enabled since they do type narrowing based on the assert.

# pyright: strict

def test(a: str | int):
    assert isinstance(a, str)
    # information: Type of "a" is "str"
    # note: Revealed type is "builtins.str"
    reveal_type(a)

Assertions not being true are not necessarily bugs. Assertions can be used to indicate assumptions that a piece of code is making. They are used to indicate that this piece of code is intended to work as expected under those assumptions. The implication that if the assumption is not satisfied, then something it wrong, is not necessarily satisfied. For every piece of code it is possible to find valid uses that are outside of what it was intended for.

3 Likes

I think typing.cast is inferior for most uses because it doesn’t do any verification at runtime.

Right sure. They can decide what they want to do. That’s why I suggested filing this as an issue with Red Knot so that they can decide if they like it, and whether it should be on by default. (They will probably collect some statistics about false positives/true positives before deciding?)

Short version:
assert statements are there for development. They are not to be relied upon for any verification purposes in production. As a development and debugging tool I don’t think that type checkers should be flagging them for removal.


I think I was fairly clear in stating this was the case if the goal was purely to correct a static type checker, not if you also wanted to do any checking at runtime.

Perhaps “at runtime” is too vague for the point I’m trying to make.

If you need verification in production, you should not be using an assert because there’s no guarantee it will be there. You should be checking the type and raising a TypeError if it is incorrect. Strict pyright will already tell you if it believes you don’t need these checks[1].

If you only want verification when developing/debugging then an assert is fine, it’s there to confirm that something you assume to be True is in fact True (and to make sure during development you don’t unintentionally make it False). If a static type checker also assumes it’s True that doesn’t necessarily mean you no longer want the check for debugging purposes (perhaps both you and the type checker are incorrect).

This may be a minor quibble, but I don’t believe the logic is based on the assert being ‘enabled’. By using the assert statement the expression is declared as True whether or not it’s actually being checked at runtime. As the expression ‘must’ be True, the type checker can then narrow accordingly.

Certainly they’re used to indicate such assumptions, but if the assumptions aren’t satisfied then you’ll be getting AssertionErrors during development. I’m not sure under which circumstances this isn’t a bug in someone’s code? Even if that bug is incorrect assumptions.


  1. I don’t think I really like this behaviour as this could be user facing code that gives a nice TypeError with a clear message instead of a more confusing AttributeError for example triggered further on. Thankfully mypy doesn’t consider this an error. ↩︎

1 Like

Well, a behavior is a bug when one had an intension but by accident something is going against that intension. If code is going to be used with new intensions, doors are open for all creativity to imagine scenarios of behaviors that would formerly be bugs but now are not.

For example, take this function written with the intension to solve degree one polynomial equations ax+b=0

def f(a, b):
    assert a != 0
    return -b/a

Here is a change of intension, that arguably is valid and in which the assertion is false. I want now to see what happens for numpy.float64

import numpy as np
a = np.float64(0.0)
b = np.float64(1.0)
c = f(a, b)
print(f'{c=}')

We run it and the assertion complains that we are using it outside the original intension. Fine. We running it with -O and we get a result that says some information about numpy.float64 and about the original function f, when used in this new environment.

I’d argue that “only works when run with -O” should count as a bug.

1 Like

I think you are having a very specific idea of code in mind. One context in which the behavior of software is common to change from being seen as bugs to just expected behavior is code acting on code. Think of testing or code analysis. Code could be taking the example function f and running it under different scenarios, including some for which f was not initially designed. The behavior of f in those are not bugs of this new code that is using f. A bug of the new code would be to not handle some exception coming from f and not being able to report or process what f did, for example.

It is data/information for the new code that f without -O and certain inputs can fail an assertion, that with -O it outputs something with whatever meaning for inputs that made the assertion fail.

This has gone off topic to whether or not a type checker should treat an assertion with an expression it believes to be True statically as something it should warn you about.

In general however, assertions should not be used for input validation (as they are in your example). Invalid user input should trigger a TypeError or a ValueError or something similar, not an AssertionError.