Spec change: ambiguous arguments in overload call evaluation

I’d like to propose two changes to the overload call evaluation algorithm in the typing spec. I’ve opened a PR with the proposal here.

Change #1:

Mypy, pyright, zuban, pyrefly, and ty all implement a rule that isn’t currently in the spec: when deciding whether a call is ambiguous, arguments that have the same parameter type in every candidate overload do not contribute any ambiguity. I’d like to add this rule to the spec.

Here’s a concrete example:

@overload
def f1(x: str, y: Literal['o1']) -> str: ...
@overload
def f1(x: str, y: str) -> int: ...

def g1(x: Any):
    # spec: Any, all type checkers: str
    reveal_type(f1(x, 'o1'))

This matters for overloaded functions like builtins.open, where this rule allows for the mode argument to select an overload even when the file type is unknown.

Here’s the proposed language in the spec (bolding is only for emphasis in this post):

For each of the remaining overloads, determine whether all arguments satisfy at
least one of the following conditions:

  • All possible :term:materializations <materialize> of the argument’s type are
    assignable to the corresponding parameter type, or
  • The parameter types corresponding to this argument in all of the remaining overloads
    are :term:equivalent.

If so, eliminate all of the subsequent remaining overloads.

Change #2:

When a call is ambiguous, the spec says to fall back to a return type of Any. Some type checkers sometimes return a more precise return type instead. This leads to different type checkers giving different results in packages like numpy and scipy-stubs - in particular, spec-compliant type checkers produce a lot of Any instead of precise types.

Here’s a concrete example:

class A[T]:
    x: T
    def f(self) -> T:
        return self.x

@overload
def f2(x: A[None]) -> A[None]: ...
@overload
def f2(x: A[Any]) -> A[Any]: ...

def g2(x: Any):
    reveal_type(f2(x))  # spec, zuban, and ty: Any, pyright: A[None], mypy and pyrefly: A[Any]

I propose to change the spec to say that the most general return type among the candidate overloads should be returned - in this example, A[Any].

Here’s the proposed language (again, bolding only for emphasis in this post):

Once this filtering process is applied for all arguments, examine the return
types of the remaining overloads. If these return types include type variables,
they should be replaced with their solved types. Eliminate every overload for
which there exists a :term:materialization <materialize> of another
overload’s return type that is not assignable to this overload’s return type.

This rule picks the most general return type, if one exists.

A couple more examples:

  • If the candidate return types were bool and int, we would pick int because bool is a subtype of int.
  • If the candidate return types were A[int] and A[str], we would fall back to Any because neither return type is assignable to the other.

Pyrefly already implements this; you can play around with it in the sandbox. (Toggle the spec-compliant-overloads field in pyrefly.toml to compare the proposed behavior to what’s currently in the spec.) We made this the default behavior in Pyrefly version 0.59.0.

Side note: I’m aware that there’s an alternate proposal to use an AnyOf type in ambiguous cases ([1], [2]). I’m proposing this materialization-based selection instead because:

  • It doesn’t require any new concepts or constructs (less complexity in the type system, less work to adopt).
  • I already implemented this in Pyrefly, so I can pretty confidently say that it seems to work well.
  • If we decide at some point in the future that this isn’t good enough, we still have the option to move to something like AnyOf. Personally, I think pinning down the semantics of AnyOf seem harder, and I’m not sure of the incremental benefit, but this change wouldn’t close the door on it.
7 Likes

I think both these changes make sense.

The second change is not mutually exclusive with AnyOf. Even with this new rule, there will remain some ambiguous matched overload sets for which a “most general” return type does not exist, and the spec mandates inferring Any. These cases would still be improved by instead inferring AnyOf the matched overload return types instead.

3 Likes