Default argument with incompatible type

This thread is an attempt to clarify another ambiguous part of the typing spec that affects overload resolution.

Consider a function whose signature includes a parameter with a type annotation and a default value whose type is not assignable to the annotated type.

def func(x: int = "") -> None: ...

This is clearly a type violation, and type checkers should report an error here. However, it’s less clear what a type checker should do in the case where code calls this function.

# This is clearly OK
func(1)

# Should this result in an error?
# Older versions of pyright (<1.1.395) do not unless
# the parameter type includes a type variable.
# The latest version of pyright (1.1.395) does.
# Mypy does not.
func()

This question is not currently answered by the typing spec. However, the answer potentially impacts overload resolution behaviors, which we’ve been trying to standardize. I’d like to see if we can achieve consensus on the preferred behavior.

Here are some additional considerations.

  1. If the annotated type of the parameter is a bare type variable or a type that includes a type variable, it arguably becomes more important to verify assignability because the type variable cannot be solved if assignment isn’t possible.
# Mypy doesn't currently support this use case, but pyright does.
def without_bound[T](x: T = "") -> T: ...
reveal_type(without_bound())  # str

def with_bound[T: int](x: T = "") -> T: ... # Error
reveal_type(with_bound())  # Error?
  1. I still occasionally encounter some libraries that use the old practice of specifying a default value of None to imply that the parameter type is Optional. This behavior has been deprecated for nearly five years in PEP 484, but pyright and mypy both provide a backward-compatibility setting to enable it. If a library uses this outdated practice, a consumer of the library may see errors in their code that cannot be cleanly mitigated without a library update or without enabling this backward-compatibility setting for their code base.
# Library code
def func(x: int = None): ...

# User code
from library import func
func()  # Error?
  1. When an ellipsis token (...) appears within a value expression, it is generally assumed to take on its runtime value whose type is EllipsisType. The typing spec specifically defines several situations where a type checker should treat ... not as a literal ellipsis but should instead apply a different meaning. Most relevant to this discussion, the spec says that a ... that appears within any value expression slot within a stub file should be treated as “any value” (i.e. an Any type). It is common practice for default argument values to use ... within stub files. But what if ... is used in this manner within a regular source (“.py”) file? For example, what if it’s used in an overload signature or protocol definition? How should ... be interpreted in this case?
# test.py

def func1[T](x: T = ...) -> T: ...
reveal_type(func1()) # EllipsisType? Any?

@overload
def func2(x: str) -> str: ...
@overload
# In the following line, does ... mean a literal ellipsis or "a value with any type"?
def func2(x: EllipsisType = ...) -> EllipsisType: ...
def func2(x: str | EllipsisType = ...) -> str | EllipsisType: ...

This distinction is important because some libraries (most notably pandas) are using ... in overload definitions within “.py” files, and it appears that their intent is for ... to have the same meaning as in a stub file.


In summary, I’m looking for clarity on two related issues:

  1. Should a type checker emit an error for a call expression that targets a callable whose default argument type doesn’t match the annotated type for the corresponding parameter? If so, should this be considered an error for purposes of rejecting an overload signature during the overload resolution process?
  2. When ... is used for a default argument value expression outside of a stub file, should it be treated the same as if it appears within a stub file? Under which conditions?

I don’t have strong opinions on either of these, but my slight inclination is that type checkers should emit an error for this condition, and ... should be interpreted as the literal ellipsis value when it’s used in a default argument expression outside of a stub file.

1 Like

Regarding (1), I think the call should not be in error. As far as the caller is concerned, the call should be treated as if the default exists and is valid. (That is, calls providing or omitting the argument should be permitted.) The actual value of the default is not relevant to the type correctness of the call; this is precisely why default values can be elided in stubs. As a rule, I think the mere existence of a type error in library code should not be inherently “infectious” to client code that may have a different author.

Regarding (2), I also have a slight preference to keep the ellipsis special case limited to stubs. If we did expand it, I strongly feel that it should only be expanded to overload definitions and possibly protocol definitions (which are “stub-like” in that they are just describing an interface for use by type checkers), not to all uses of ... in a .py file.

6 Likes

The presence of an explicit default argument (so not a ...) suggests that the function is designed to be called with- or without passing it. This does not change if this default value is not assignable to its annotated type. The user is not responsible for this error (generally speaking), and cannot do anything about it (from within their own codebase). So I don’t consider it helpful to report this error to the user.

4 Likes

IMHO, no. The error should be emitted on the analysis of the callable declaration. The caller didn’t make an error. The person who wrote the callable declaration made the error.

Since my answer was no, this isn’t relevant.

In a python file, the ... in an overload should be treated the same way as in a stub file. Anywhere else as the constant value Ellipsis .

In pandas, the typing in the pandas source supports the use case of the developers of pandas. pandas-stubs supports the use case of people using pandas as a library. Maybe someday they will converge.

I also have code that has no stub files, for which we use overloads in python source, and we want the use of ellipses in those overloads to have the same meaning as in stub files. Example:

    @overload
    def get_variables(self, var_type: str) -> str: ...

    @overload
    def get_variables(self, var_type: Literal[None] = ...) -> dict[str, int]: ...

    def get_variables(
        self, var_type: str | None = None
    ) -> str | dict[str, int]:
        # Code here

I think the above use case needs to be supported where you use ellipses in an overload declaration within a python source file. Or maybe you have another way of handling that use case (besides creating an adjacent .pyi file)?

Thanks for the quick and thoughtful responses.

On question 1, there appears to be a consensus that this should not be reported as an error at the call site and should not affect overload matching. The arguments made in favor of this viewpoint are compelling.

On question 2, it sounds like opinions are more mixed.

@Dr-Irv, to address your point… you can provide the real default value rather than .... This approach is preferable (even in type stubs) now that type information is used by millions of Python developers to power language server features. If the real default value is provided, editors can display additional relevant information to consumers of APIs.

I think Carl makes a good point on question 2. I’d likewise prefer not to extend the ellipsis special case beyond stubs.

As a next step, I’ll work on proposed wording for a typing spec update. Since this will involve a new section on call evaluation, I’ll probably also include wording for unpacked arguments based on this thread.

2 Likes

I’ve used ... in default values on Protocol methods in source files.

Not sure that there’s a great alternative, since the default value could be different in concrete implementations.

7 Likes

I think the ellipsis should be allowed in overloads in source files. The mypy docs (just above this anchor: More types - mypy 1.15.0 documentation) have this:


The default values of a function’s arguments don’t affect its signature – only the absence or presence of a default value does. So in order to reduce redundancy, it’s possible to replace default values in overload definitions with ... as a placeholder:

from typing import overload

class M: ...

@overload
def get_model(model_or_pk: M, flag: bool = ...) -> M: ...
@overload
def get_model(model_or_pk: int, flag: bool = ...) -> M | None: ...

def get_model(model_or_pk: int | M, flag: bool = True) -> M | None:
    ...

So anyone who has been using typing for a while based on those docs will have ellipsis in their overloads in source files.

Which corresponds to what Carl said about expanding it to source files: