Specifying invalid signatures in overloads

During the last typing meetup @erictraut mentioned that for time to time people look for ways to mark a specific overload as invalid. It often leads to developers using NoReturn / Never for it which is incorrect. See also the great video by anthony on that topic: https://www.youtube.com/watch?v=WuXRn3euN8k

Example: How to annotate func so it doesn’t accept normal strings as input and only sequences?

def func(arg: Sequence[str]) -> int: ...

func(["Hello", "World"])  # ok
func("abc")  # should be an error

After thinking about it, I believe this might be fairly simple to archive if we’re to add a new SpecialForm which could be used as return type annotation for these cases, say NoMatch. Since the overload matching happens based on the arguments, it wouldn’t need to change. Type checkers just need to check afterwards if the matched return type is NoMatch and emit an error accordingly. The overloads for the case above would then look like this

@overload
def func(arg: str) -> NoMatch: ...
@overload
def func(arg: Sequence[str]) -> int: ...

An obvious advantage would be that this closely mirrors what people expect would work with NoReturn.

Open questions

  1. Is this something worth adding support for?
  2. Is the return type annotation the best option? In passing, Eric mentioned maybe adding an @add_error decorator.
  3. If return type annotation, is NoMatch the best name?

Prototype

I prototyped the implementation in mypy. For tests, it can be imported from from typing_extensions import NoMatch.

5 Likes

IMO, yes, but it shouldn’t be restricted to @overload, but also work for e.g. __bool__ implementations that raise an error, see a recent discussion on this forum. (Yes, this encourages violations of the LSP. But static typing needs to describe real world code, and such code does often violate the LSP.)

I think a decorator is cleaner than a type. After all, this isn’t really a type in the normal sense - the return type continues to be Never since the implementation presumably raises an error, i.e. “returns” the bottom type. And adding a third type to the list NoReturn, Never that seems very similar is going to be unnecessarily confusing. And we also already have something similar with @deprecated

For a name, I am honestly not sure. Maybe @error("Message"), which is similar to constructs in other languages with a related purpose? (See the C PreProcessor or nim pragmas). But having a public symbol just named error is a bit confusing.

1 Like

Note that there’s a need for this in the standard library.

This currently doesn’t give any type error from type checkers, but it crashes with TypeError:

from collections import Counter


my_counter = Counter({"a": "b"})
my_counter.total()

This isn’t incorrect, but the current consequences of it aren’t what people expect the outcome to be. The distinction here is important because it means we can fix this without needing any additional type-theory or LSP violations. I think it would be better to fix this by making the behavior people expect here for callers to actually happen.

There’s 3 ways I see to fix this, and 1 non-fix

  • In the prior thread about numpy and __bool__, I mentioned generalized algebraic effects. This would be the “ideal”, but it’s also not something that would be trivial to add to python’s type system.

  • Add a New special form that means “This is Never for the purposes of the type system, but when it’s the return type, The caller is using it wrong, and they should get an error about this.” (I explored this option as part of an on-hold experiment before, and believe it’s viable here)

  • Add intersections and type negation so that overloads can be specified more accurately to avoid some of the need. This won’t address every case of the use of Never, but will cover that of current use in only some overloads.

  • continue with the status quo, docs + runtime errors are enough, and Never is correct from the type-theory perspective.

Do you have any more motivating examples? Isn’t the string one handled by useful_types.SequenceNotStr?

useful_types.SequenceNotStr is problematic for multiple reasons, as the way it tries to get clever to avoid working with str breaks other checks by being too broad, and those are much more important than the str vs Sequence[str] check. I would suggest people not use this.

I think the current proposal is a bit too narrow. I think the feature would also be useful in non-overloaded functions.

When writing third party stubs for typeshed there’s a pattern I came across quite a few times, which also happens to match the use-case the prior thread about __bool__ inquired about. I.e. overwriting a method in a subclass with an implementation that always raises and having the type checker understand, that it’s an error to call that method on that type.

To be fair I think of this as an anti-pattern, since you’re potentially creating hidden mines in the supertypes for people to step on, but nevertheless, given that these types already exist, it would be more useful for type checkers to be able to emit a custom error message when one of these methods is called on a type, where it’s known to be an error.

So I would propose using the previously suggested @type_error decorator[1], which mirrors the behavior of @deprecated. Except it is treated as a generic error.

The other advantage of using a decorator, is that you can control what the fallback type will be when the type checker matches this overload[2].

A decorator like this also opens up some additional design space for making this feature even more flexible, a couple of possibilities come to mind:

  1. Optional level parameter. In order to be able to emit lower confidence/severity errors that can be turned off by default.

  2. Optional category parameter. In order to give people the ability to silence a specific kind of @type_error, e.g. str-as-iterable via type: ignore[str-as-iterable], but not others.


Beyond that I think it’s important to specify what happens with overlapping overloads, where only one of them is marked an error.

This sounds trivial at first, but has similar subtle complexities like the overload system already does. E.g. imagine a function with overloads for Iterable[T], str and Literal["foo"], but only str is an error. All three overlap, but intuitively you only want this to emit an error when you pass a str that isn’t "foo".

Maybe this is too specialized, so it’s not worth supporting complex cases like this, but either way there should at least be a sketch of the desired logic for emitting errors with overlapping overloads.


  1. I think Jelle first came up with that name, but I might be wrong, so please don’t be mad if you’re the person that actually came up with this idea ↩︎

  2. In most cases you would probably want it to be Never, but I can think of cases where you use @type_error for something that doesn’t actually produce a runtime error, like passing str into (Iterable[T]) -> list[T], you would still want the return type to be list[str], but you would also want to emit an error ↩︎

If the overloads were defined in the order as you’ve mentioned, I believe Iterable[T] is a full overlap (unless T is constrained) for str, and str is a full overlap for Literal["foo"]. A type checker should emit an error at the overload definition site, since the other two won’t be ever matched.

The solution should be to reverse the order, so the first overload catches Literal["foo"] (and emits an error due to @type_error)

I wasn’t really suggesting to define the overloads in that order, the most specific overload should of course generally come first if it overlaps with a broader one.

I agree that something clear-cut like this would be nice. Unfortunately, the order of the overloads matters remarkably little[1]. See Eric’s draft of the overloads chapter with a sketch of how overload resolution actually works today: https://github.com/python/typing/pull/1839

Whether or not a type checker would emit an error at the definition site for partially overlapping overloads depends on the return types of each overload, but a @type_error doesn’t necessarily result in a conflicting return type.

I mostly brought this up to illustrate that the rules for @type_error would need to be carefully drafted, so @type_error does the most useful thing, without making the overload matching algorithm even more complicated and difficult to understand.

At a glance, the most useful behavior to me would be, after eliminating all the non-matching overloads, to emit the error if the first remaining candidate is decorated with @type_error, regardless of whether or not it conflicts with one of the later remaining candidates, without changing how the return type is determined in the ambiguous case.

But I haven’t thought hard enough about it to be confident, that there’s no issues with doing things this way. PEP 702 appears to work that way, although the wording is so vague, I don’t really know what was intended to happen in the ambiguous case. For deprecations that seems fine, since it doesn’t seem very likely that you will hit the ambiguous case. But for type_error I expect ambiguous cases to be much more common.


  1. I think this is a real shame, a more simple rule like “First match” wins would have been a lot easier to implement and understand, but it’s kind of difficult to change course at this point, since it has worked differently for so long now ↩︎

I did read it prior, but I don’t think I understand your point.

That might be true, but it definitely matters in determining the validity of overlapping overloads, which is relevant to my previous comment.


Now, for my own sake I’ll try to go over the algorithm given the following overloads:

@overload
@type_error
def foo(s: Literal["foo"], /) -> Never: ...
@overload
def foo(s: str, /) -> list[str]: ...
@overload
def foo[T](s: abc.Iterable[T], /) -> list[T]: ...

…and a call of foo("foo").

  1. step shouldn’t eliminate any of the overloads, since all have 1 positional param, and there’s 1 positional argument.

  2. step evaluates the overloads like independent function calls, and records errors.
    Presumably, this is when the overload with @type_error would be noted as producing an error, but since the two other overloads match and do not produce one, evaluation continues.

  3. step doesn’t apply, since not all overloads result in an error.

  4. step doesn’t apply, since there’s no *args nor **kwargs.

  5. step replaces the T with str. Since the two remaining overloads have equivalent return type, go to 6.

  6. step chooses the 2nd overload ((str) -> list[str]) as winning match.

It seems to me that under those rules, the 2nd step would discard the first overload due to the error, and the evaluation proceeds normally.
The overload resolution would need to specifically check for @type_error at the 2nd step (and possibly later?) for this to work.

You basically illustrated my point with your example. @type_error would be pretty useless if it worked like that, that’s kind of what I was getting at.

So for the purposes of overload resolution a @type_error shouldn’t be able to eliminate an overload. Otherwise that overload would never be matched.

But there’s a problem with the ambiguous return types part in step 5. Since you stop the algorithm and return Any no overload gets chosen, so it would once again follow that no error is emitted.

But that’s also kind of bad[1], so you probably still want to do step 6 anyways for the purposes of emitting a potential @type_error or @deprecated.

But you could also make arguments for different behaviors. This is just the one that makes the most sense to me.


  1. even though you would get an error at the definition site for the partial overload with incompatible returns ↩︎