Permitting more precise inferred types from ambiguous overloads

The typing spec and conformance suite currently require that if overload resolution is ambiguous due to gradual argument types, the return type must be inferred as Any.

This is motivated by maintaining graduality of inputs in the output. If an argument type of Any makes an overloaded call possibly resolve to either int or str, the return type should be usable as either int or str without error, otherwise we’ve effectively lost the graduality requested by the original Any. This motivation is well-founded.

But often a more precise gradual type can meet this requirement, and preserve more unambiguous type information. Mypy already implements this in some cases (contrary to the current spec). If an overloaded call might ambiguously return list[int] or list[str], mypy will infer list[Any] rather than just Any. This preserves the unambiguous “is a list” information, while still allowing the return to be used either as a list[str] or a list[int].

I propose a change to the typing spec and conformance suite that replaces the mandate to infer Any with a mandate to infer some type that meets the gradual requirement outlined above. Any obviously meets this requirement, so inferring Any will continue to be valid under my proposed change. But this change will make mypy’s current behavior spec-conforming, and will allow type checkers to experiment with inferring more precise types without losing conformance.

The PR is at Allow non-Any gradual return types for ambiguous overloads by carljm · Pull Request #2350 · python/typing · GitHub – comments welcome.

9 Likes

While this seems like a positive change, I want to make sure I understand the outcome of a specific case.

Given the below:

@overload
def example(foo: A) -> int:  ...
@overload
def example(foo: B) -> str:  ...
def example(foo):  ...


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

example(something_else())  # I would expect `int | str`, not Any and not a future AnyOf[int, str] either

In this case, it isn’t overloads that are ambiguous, it’s the input could be either and isn’t gradual, so the return type should reflect that. If I’m reading the proposal correctly, this is still the case and I would be on board with the proposal.

Yeah, your example is unaffected by this proposal, because this step in the overload evaluation algorithm applies only when multiple overloads match due to a gradually-typed argument.

Your example exercises a different step in overload evaluation (union expansion) – when multiple overloads match due to union expansion, their return types should correctly be unioned together.

2 Likes

Am I understanding it correctly that we ideally want the return type to be exactly the gradual type that contains all of the remaining return types? And we don’t want to require this behaviour because type checkers generally don’t implement arbitrary gradual types?

1 Like

Yes, ideally would probably be AnyOf[R1, R2, ..., Rn] where R1, …, Rn are the matching return types. AnyOf is a gradual version of Union that is assignable to any of its constituents.

1 Like

The newly added text in the linked PR seems to exactly describe the AnyOf type linked above:

For example, if the remaining return types are int and str, the result
should be assignable to both int and str, and both result + 1 and
result.upper() should be accepted. Inferring the ordinary union int | str
would not satisfy these requirements.

I guess one worry with this is that you’re instructing type checkers to return a type that can’t currently be expressed in the typing system.

Well no. Any would remain a perfectly valid inference here. The change only allows type checkers to get more specific if they like.

1 Like

Yes, you all have it right. The ideal type would be the gradual type that can materialize to precisely the possible return types of the ambiguous overloads, but I don’t think we are in a position to require that type without a PEP that allows expressing such types.

This is intended to be an incremental change that allows type checkers to improve on the status quo in whatever ways work within their model (as mypy already does), without blocking on a PEP-level addition to the type system. It’s not meant to preclude (nor require) possible future updates to specify something more prescriptive.

4 Likes

The wording in this change is satisfied by the precise AnyOf type but does not require it. It’s also satisfied by simply Any. Or by some types more precise than Any but less precise than exactly the AnyOf; for example mypy’s list[Any] is not precisely AnyOf[list[int], list[str]], but it’s a much closer approximation than Any.

3 Likes

Makes sense :+1:

While this proposal permits more precise inferred return types than the current spec, mypy, pyright, and pyrefly all deviate from the overload evaluation section of the spec, and at least for pyrefly, this would force us to produce less precise types across the board.

I ran pyrefly on mypy primer with its default semantics (choose a return type that all materializations of all other candidate return types are assignable to, or Any if such a return type doesn’t exist), then with those semantics plus requiring that the chosen return type be assignable to all other candidate return types (let’s call this “safe” semantics). There were changes in 122 projects, and the error delta was:

Error kind                     Added  Removed      Net
---------------------------  -------  -------  -------
unknown-argument-type           6507      239    +6268
unknown-variable-type           3365       83    +3282
implicit-bool                     92      352     -260
assert-type                      300       19     +281
no-any-return-implicit           260        0     +260
unsupported-operation             74      184     -110
bad-argument-type                 39      200     -161
missing-attribute                 22      216     -194
[remaining error kinds had less than 100 added or removed errors apiece]
---------------------------  -------  -------  -------
Total                          10842     1679    +9163

The dominant impact is added errors like unknown-argument-type from producing more Anys, although there are also some removed errors, like unsupported-operation, which could be fixed false positives. (Note: the error kinds for unknown types are off-by-default, so I’m not claiming that pyrefly would actually produce 11k more errors, just using the errors as a measure of how types would shift.)

I also took a closer look at two specific projects, numpy and scipy-stubs, which both have a lot of overloaded functions and extensive typing test suites, and found plenty of examples where an assert_type expected a type that would violate the proposed new requirement.

(Everything from this point onward is an analysis that I was originally compiling for Spec change: ambiguous arguments in overload call evaluation . I kept putting off posting it because I didn’t have a good solution to propose. These numbers are about 2 months old.)

I ran pyrefly on numpy and scipy-stubs’ typing tests with default semantics, then safe semantics, and examined new assert_type failures. There were 169 new failures, and the categories they fell into were:

  • In 81 cases (48%), one of the overloads returned a union containing the other return types. E.g., A | B, A, B => A | B is selected.

  • In 56 cases (33%), one of the overloads returned a union with Any, which caused the other return types to be assignable to it. E.g. A | Any, B => A | Any is selected.

  • 29 cases (17%) were the union case but with the union in a type argument. E.g., X[A | B], X[A], X[B] => X[A | B] is selected.

  • Finally, in 3 cases (2%), one of the overloads returned a variable-length tuple and the others fixed-length tuples. E.g., tuple[A, ...], tuple[A, A], tuple[A, A, A] => tuple[A, ...] is selected.

For what it’s worth, mypy and pyright seem to get the expected answer in these cases not by picking intelligently but by selecting the first matching overload [1], and the overloads are ordered so the most general one comes first. Regardless, the pattern seems to be that rather than all of the overload signatures being equal, so to speak, one of them is intended to be a fallback. Requiring that the inferred return type be assignable to all of the return types would prevent a type checker from choosing the fallback in the above cases. The mypy primer numbers suggest that this isn’t an isolated issue.

[1] Example of pyright picking the first overload in an ambiguous case:

from typing import Any, overload

class A[T]: ...

@overload
def f(x: A[int]) -> int: ...
@overload
def f(x: A[str]) -> str: ...
def f(x) -> Any: ...

def g(x: A[Any]):
    reveal_type(f(x))  # int

Example with mypy (note that the type alias seems to be needed to hide the Any from mypy):

from typing import Any

class A[T1, T2]: ...

type X[T] = A[Any, T]

from typing import overload
@overload
def f(x: A[int, int]) -> int: ...
@overload
def f(x: A[str, int]) -> str: ...
def f(x) -> int | str:
    return 0
    
def g(x: X[int], y: A[Any, int]) -> None:
    reveal_type(f(x))  # int
    reveal_type(f(y))  # but this is `Any`!
2 Likes

In case of NumPy the assert_type tests are almost always specifically tuned so that mypy accept them. For scipy-stubs they are checked using mypy, (based)pyright, and pyrefly, where I often chose the path of least resistance and tuned the assert_type tests for the majority; i.e. minimizing the type ignore comments. So for practicality I followed current checker behavior rather than the typing spec.

So don’t worry too much about those; I wouldn’t mind adjusting those type tests to make them more compatible with the spec :slight_smile:

2 Likes

Could you provide an example of such an affected function? I also feel it’s a bit unfair to say that this spec change would force less precise types when you’re already deviating from the spec anyway :stuck_out_tongue:

2 Likes

The change I’m proposing is, I think, a clear improvement over the current language in the spec and the requirements in the conformance suite today. Today the spec language absolutely requires Any in all cases of gradual ambiguity; this proposal permits more precise gradual types than Any, without losing the input graduality.

Your suggestion is that maybe we should go even further, and be also willing to accept some false positives due to inferring a static return type with greater confidence than can be substantiated by the gradual arguments to the call.

That’s a reasonable discussion to have, but I think it gets much more complex and debatable, and I would strongly prefer to separate that question from my current proposal, so that we avoid “letting the (maybe) better be the enemy of the good”, and end up with no improvement at all in the short term.

So I would prefer to limit this discussion to this question: given that accepting this proposal does not close the door to further future proposals to adjust the specification of ambiguous overloads, does the current proposal improve on the status quo?

2 Likes

Fair, if I’m being more careful with my wording, following this requirement would force pyrefly to produce less precise types.

To be clear, if this change lands, I intend to update pyrefly to respect it. For some background, pyrefly followed the overload evaluation section of the spec exactly until this April, when I changed its default behavior because it disagreed with the existing ecosystem in too many places. I also put up a proposed spec change at the time, because the intent was never to deviate from the spec long-term; IMO that’s bad for the type system in all sorts of ways.

I also really don’t want to churn pyrefly’s behavior repeatedly, so I’d like to make sure that we’re really okay with the cost of type checkers following the spec here. If we’re all on the same page, I’m not going to try to block this or anything; this would actually make things easier on me from a maintenance perspective because I could just delete pyrefly’s entire --spec-compliant-overloads flag and replace it with a three-line loop :slight_smile:

Here is an actual example from scipy-stubs, although I’m not sure how illuminating it is.

2 Likes

I support this change but I’d like to discuss the background a bit more.

Let’s look at this example a bit more (taken from the conformance suite):

@overload
def example5(obj: list[int]) -> list[int]: ...
@overload
def example5(obj: list[str]) -> list[str]: ...
def example5(obj: Any) -> list[Any]:
    return []


def check_example5(b: list[Any]) -> None:
    assert_type(example5(b), Any)

If we pass a fully static type to example5, we always get a fully static result: list[int] (for lists of ints), list[str] (for lists of strs), or an error (for anything else). But what if we get a gradual type such as list[Any] that could materialize to the parameter type of either overload?

In that case, the return type could be that of any of the overloads. And we ideally want to preserve the gradual guarantee, which means that if we replace the list[Any] type with a more static one, we should not remove any errors that the type checker emitted with the gradual type.

So we want the inferred return type to be (1) a gradual type that can materialize to all of the return types from the overloads. Ideally it (2) shouldn’t be able to materialize to too much else, because that makes the code too permissive. In a type system with intersections, one way to do that is to infer the type (A & B) | ((A | B) & Any), which is a type that can materialize to the union of the two types, their intersection, and anything in between. In our example, this would simplify to (list[int] | list[str]) & Any.

But we currently don’t have intersections in the specified type system. The current spec instead picks Any, which fulfills the first requirement (it can materialize to any return type) but doesn’t do great on the second (it can materialize to a great many unrelated types too). Carl’s proposal is essentially to specify only this first requirement and allow type checkers to decide how precise to make their gradual types. Any would still fullfil this requirement, but so would list[Any] or (list[int] | list[str]) & Any.

The pyrefly behavior described by Rebecca doesn’t follow the gradual guarantee. Its behavior is visible in this modification of the example:

from typing import Any, overload, assert_type

@overload
def example5(obj: list[int]) -> list[int] | list[str]: ...
@overload
def example5(obj: list[str]) -> list[str]: ...
def example5(obj: Any) -> list[Any]:
    return []


def check_example5(b: list[Any]) -> None:
    reveal_type(example5(b))

Here pyrefly infers list[int] | list[str]. This violates the gradual guarantee, because if b was a list[str] instead, we’d get the more precise type list[str]. I’d prefer to maintain the guarantee here and infer a type like list[str] | (list[int] & Any), or list[Any].

4 Likes

Thanks @Jelle for outlining this in more detail.

I do think we are in a slightly difficult position vis-a-vis the current ecosystem, because some prominent projects that use overloads heavily have (reasonably) designed their overloads around the current behavior of mypy and pyright. (In an ironic/unfortunate twist, it looks like mypy intended to implement gradual handling of gradually ambiguous overloads, but has a bug where type aliases – which should be transparent – are allowed to “hide” the gradual ambiguity, and that mypy bug may have been the original trigger for much of this pick-the-first-overload behavior.)

For type checkers (like pyright and pyrefly, or mypy when type aliases are involved) which currently pick one of the gradually-ambiguous overloads rather than constructing a gradual type that encompasses all of them, I expect that a switch to simply inferring Any instead will, in the short term, be experienced by users as a regression in precision, to the extent that current ecosystem projects have molded their overloads around the current behavior.

One example of this “molding around the current behavior” is that pandas-stubs will often add an initial overload with a signature like (Sequence[Never]) -> Series[Any], just to “catch” Sequence[Any], specifically so that type-checkers which “pick the first” won’t wrongly/arbitrarily pick the next overload (e.g. (Sequence[bool]) -> Series[bool]) when the argument is Sequence[Any]. But this is not a very clear pattern (nobody actually expects an argument of type Sequence[Never]), and it causes problems when type checkers are able to correctly recognize that e.g. the empty string inhabits Sequence[Never]. So this is a case where the current ecosystem has already had to develop sub-optimal workarounds for the “guess at one of the overloads” behavior.

I think the precision regression experienced by users will be much less to the extent that type checkers are able to infer more precise types (like mypy’s list[Any] in my OP example, or the even more precise types outlined by @Jelle) that still follow the gradual requirement. In the pandas case I discuss above, that could allow type checkers to automatically infer Series[Any] (or a more precise type that still gradually covers all the possibilities) rather than just Any for a gradually ambiguous case, which would remove the need for that Sequence[Never] overload entirely.

So although I don’t expect the transition to be entirely painless, I do feel like the best path forward is to deprecate this “pick one” behavior and preserve graduality when overloads are gradually ambiguous.

3 Likes

For Pyrefly, I’ve updated how it resolves ambiguous overloads, so that the “most general” return type it selects also has to be assignable to all of the candidate return types. Thanks all for the discussion.

4 Likes

I think that “must be assignable to all other candidate overload return types” may not be a sufficient guard to implement the graduality requirement that the spec requires (either now, or after my change).

Consider this case (simplified from the scipy case you linked above):

from typing import Any, assert_type, overload

@overload
def f(x: list[int]) -> float | Any: ...
@overload
def f(x: object) -> Any: ...


x: list[Any]
reveal_type(f(x))

In this case the argument type list[Any] gives us no basis for preferring one overload over the other; list[Any] may be e.g. list[str], which would match the second overload, not the first.

Currently pyright and pyrefly infer float | Any for this call, meaning the return type would error if assigned to e.g. a str. So float | Any and Any are mutually assignable to each other, but that does not mean that float | Any can be used in all the same ways as Any. So per my PR, a type checker would not be free to pick arbitrarily between float | Any and Any here – the only valid inference for this call would be Any.

1 Like

Ah, that’s my bad, I didn’t implement the “permits operations supported by any of them without
errors” piece. Thanks for the correction.

1 Like