"Wrong" Special Form

I often see people use Never or NoReturn as a return type combined with overloading to express calling a function that way is not valid.

But isn’t that kind of abusing the feature for something it is not made for?

Never (and NoReturn) just indicate that something will never return (bottom type). This is all they tell us. We cannot judge whether calling a function with a Never return is expected or supposed to be treated as an error, that should be outside of the scope of their usage.

That’s why I want to propose the Wrong special form.
When the type checker gets Wrong as an expression result, it errors.

Examples

Currently this errors in the line if a for pyright, but not for mypy.

from typing import Never

class A:
    def __bool__(self) -> Never:
        raise RuntimeError

a = A()

if a:
    print("I will never be reached")

I think mypy is correct in this case and pyright just specialized erroring in moments like this, because Never is often ‘misused’ this way.
Who says I do not purposefully want to never return from this function?

Look at the following example:

from typing import Literal, Never

class A():
    def __bool__(self) -> Literal[True]:
        return True

class B():
    def __bool__(self) -> Never:
        raise RuntimeError


def get() -> A | B:
    ...

a_or_b = get()

if a_or_b:
    print("I might be reached")

Pyright still errors here, even though I have practical reasons why I would do this and there is no technical reason this should be wrong.

If we add a Wrong type, Never can stay true to its real use case of just showing something does have no possible value, while Wrong explicitly tells us that we indeed did something wrong.

Edit: Change typo Specia to Special, remove unused imports in coding examples

4 Likes

For the overload case, adding something like @overload.never might be a better use experience.

2 Likes

It is, yes! https://www.youtube.com/watch?v=WuXRn3euN8k is a nice example. I love the idea.

2 Likes

Would be useful to also add messages to Wrong so that type checkers show it directly.

Like:

from typing import Literal, Wrong

class A:
    def __bool__(self) -> Literal[True]:
        return True

class B:
    def __bool__(self) -> Wrong["can't determine true value of B"]:
        raise RuntimeError

def get() -> A | B: ...

a_or_b = get()
if a_or_b:
    print("I might be reached")

The error could then look like:

Calling method __bool__ of type "B" is illegal (note: "can't determine true value of B")
2 Likes

Sequence[str] is a very good example! Wrong would pretty much allow type negation.

How would you like the new Special Form to be called, if it was introduced.

  • Bad
  • Illegal
  • Incorrect
  • Wrong
  • Something else (reply)
  • I don’t know/care / Results
0 voters

Maybe “Forbidden”? Or that least that’s the tone I’m going for whenever I incorrectly annotate Never. “This operation is specifically disallowed.”

2 Likes

I think this is a useful idea.

I’d prefer TypeError["optional custom message here"] for static type errors, and for these to be valid in any type expression, allowing these to be aliased for reused messages. Custom messages run into some interactions with localization, so it may be worth having a standard set of messages as enum values as well for known common cases and for this set of standardized messages to be maintained as a joint effort to avoid or at least reduce duplication of localization efforts.

As a special form, this should be viewed as a type error to be raised to the user, and be equivalent to Never in terms of actual type theory implications.

6 Likes

Perhaps standard errors could be class attributes, like TypeError.MISSING_ARG. If not recognised by a checker, it should warn at definition sites, but still treat as the special form. You’d then have the indexing syntax for a custom message.

Not sure about TypeError as the name, wouldn’t want that to shadow the builtin exception.

I like the idea of annotating raised exceptions at first, but it appears this idea has been discussed before and rejected.

Not even the first time this was brought up in relation to Never according to the backlinked discussions.

There is a big difference between annotating what exceptions can be raised by a function at runtime and marking something that a type checker should report as an error at type checking time. What @mikeshardmind suggested is not the same thing at all as what is discussed in the linked thread.

Maybe this means that reusing the name TypeError might be confusing though.

3 Likes

I assumed the resulting implementation would be the same even if the intention was different. My bad since that wasn’t the case.

I think part of my confusion was that TypeError implies static-time errors but isn’t static-time itself.

Forbidden["reason"] is a possible alternative. As long as it only holds a string and not an exception.

2 Likes

I have suggested typing.Error for this before.

The standard library could benefit from this with collections.Counter

Counter overloads are unsafe

from collections import Counter

# no type errors reported and nothing tricky going on
a = {"a": "hello"}
b = Counter(a)
c = b.get("a", 0)
print(c + 2)  # crash

typeshed has this:

    @overload
    def __init__(self, mapping: SupportsKeysAndGetItem[_T, int], /) -> None: ...
    @overload
    def __init__(self, iterable: Iterable[_T], /) -> None: ...

could be changed to something like this:

    @overload
    def __init__(self, mapping: SupportsKeysAndGetItem[_T, int], /) -> None: ...
    @overload
    def __init__(self, mapping: SupportsKeysAndGetItem[_T, object], /) -> typing.Error: ...
    @overload
    def __init__(self, iterable: Iterable[_T], /) -> None: ...
2 Likes

I think calling it Error could cause confusion with exceptions, I would rather prefer Forbidden, Wrong or Bad or anything similar over it.

3 Likes

Can you provide different examples? __bool__ having a return type other than bool feels like an error to fix, not a use case to support.

In some way, there is no difference between not returning and calling the function incorrectly. Either way, you have a partial function, one that does not return a value for every possible argument.

It is a usecase that should be supported. E.g. numpy arrays error on this with very good reasons.

3 Likes

I think that this is a great idea!

People thinking/using Never to mean “this function shouldn’t be called” is a personal pet peeve of mine. Even the slightly more conservative “Never means it raises an Exception” interpretation is wrong since

def start_server() -> Never:
    while True:
        handle_request()

is perfectly valid and it might not raise any exceptions.

As you mentioned, Never is just the bottom/uninhabited type, nothing less and nothing more. Also, IMHO, any type checkers that produce errors for unreachable code are trying to do the job of linters (and doing it poorly).

Having a dedicated “YouAreDoingSomethingWrong” type would both be useful from a practical standpoint and would help to dissuade people from (ab)using Never to mean “Don’t use this”.

I think that Illegal would be the best name for this special form, since it makes more sense when used in a sentence. For example, “X is a TypeError” might sound like X is just an instance of some TypeError class, whereas “X is Illegal” makes it more clear that just the mere existence of this “X” is itself illegal.

I am not sure, what you mean. I think that OPs intention was actually for Wrong (or TypeError) to be specifically a static-time-only error.

Since this Wrong/Illegal special form is supposed to also represent an uninhabited type, then it doesn’t even exist at runtime. Instead, it behaves like Never, but with the extra caveat that type checkers should treat any instances of this type as a type error.

2 Likes

Everything you said about the point I’m trying to make is correct, just wanted to clarify it and I think you explained it better than I could!

Does that mean issubclass(Wrong, Never) and issubclass(Never, Wrong) and Wrong is not Never?
In types as (logical) sets (not set) that’s not possible, I think. There’s only one empty set ø: the set of quadratic primes equals the set of living T Rex. Is it possible in type theory?

Conceptually, I’d say that issubclass(Wrong, Never) would be True, but not the other way around. A value that is Wrong to use in code should Never exist, but there are functions that Never return that are not Wrong to call.

In practice, neither issubclass would be true, because Never is currently a special form and Wrong would also be a special form. And for special forms:

issubclass(T, typing.Never)

just always raises

TypeError: typing.Never cannot be used with issubclass()

Special forms are tricky like that. For example, basically the same problem exists for any T and Annotated[T, "blah"]. And in this case, I don’t think the ability to do issubclass/isinstance checks is even useful, so in practice this should be a non-issue. In fact, it might be easier to think about Wrong like it’s some kind of Annotated[Never, ...]-like type/special form.

Also, Never doesn’t really interact very nicely with the “naive” set-theoretical interpretation of typing anyway. You can read my opinion on the matter in this thread.

1 Like