pythonmypypython-typing

How to type-overload functions with multiple optional args?


I have a function with multiple kwargs with defaults. One of them (in the middle somewhere) is a boolean toggle that controls the return type.

I would like to create two overloads for this method with Literal[True/False] but keeping the default value.

My idea was the following:

from typing import overload, Literal

@overload
def x(a: int = 5, t: Literal[True] = True, b: int = 5) -> int: ...

@overload
def x(a: int = 5, t: Literal[False] = False, b: int = 5) -> str: ...

def x(a: int = 5, t: bool = True, b: int = 5) -> int | str:
    if t:
        return 5
    return "asd"

But mypy raises:

error: Overloaded function signatures 1 and 2 overlap with incompatible return types

I assume that is because x() will conflict.

But I cannot remove the default = False value in the second overload since it is preceded by arg a with a default.

How can I overload this properly such that


Solution

  • Compatibility note

    Recent mypy versions (1.11.0 and newer, I believe) accept the original code as-is. It was intentionally done to make solving the OP problem easier. mypy now accepts the code in question as-is. I do not agree with this decision, but that's out of scope for a SO answer - see this ticket if you're interested in more details.

    Old answer (mypy 1.10.x and below)

    It is an old problem. The reason is that you specify default value in both branches, so x() is possible in both and return type is undefined.

    I have the following pattern for such cases:

    from typing import overload, Literal
    
    @overload
    def x(a: int = ..., t: Literal[True] = True, b: int = ...) -> int: ...
    
    @overload
    def x(a: int = ..., *, t: Literal[False], b: int = ...) -> str: ...
    
    @overload
    def x(a: int, t: Literal[False], b: int = ...) -> str: ...
    
    def x(a: int = 5, t: bool = True, b: int = 1) -> int | str:
        if t:
            return 5
        return "asd"
    

    Why and how? You have to think about ways to call your function. First, you can provide a, then t can be given as kwarg (#2) or arg (#3). You can also leave a default, then t is always a kwarg (#2 again). This is needed to prevent putting arg after kwarg, which is SyntaxError. Overloading on more than one parameter is more difficult, but possible this way too:

    @overload
    def f(a: int = ..., b: Literal[True] = ..., c: Literal[True] = ...) -> int: ...
    
    @overload
    def f(a: int = ..., *, b: Literal[False], c: Literal[True] = ...) -> Literal['True']: ...
    
    @overload
    def f(a: int = ..., *, b: Literal[False], c: Literal[False]) -> Literal['False']: ...
    
    @overload
    def f(a: int, b: Literal[False], c: Literal[True] = ...) -> Literal['True']: ...
    
    @overload
    def f(a: int, b: Literal[False], c: Literal[False]) -> Literal['False']: ...
    
    def f(a: int = 1, b: bool = True, c: bool = True) -> int | Literal['True', 'False']:
        return a if b else ('True' if c else 'False')  # mypy doesn't like str(c)
    

    You can play with overloading here.

    Ellipsis (...) in overloaded signatures default values means "Has a default, see implementation for its value". It is no different from the actual value for the type checker, but makes your code saner (default values are defined only in actual signatures and not repeated).