pythonpattern-matchingpython-typingpyright

Handling Python Type Checker Errors with Return Type Declarations (fields default values = None)


I'm encountering an issue with my Python code using Pyright type checker, specifically when I try to remove Any from the return type declaration.

Error message:

Function with declared return type "int" must return value on all code paths. "None" is incompatible with "int".

Now every single path returns an int at some point, is this a bug or I just have to use the Union thing as a python thing.

from dataclasses import dataclass
from typing import Any, Union, List

@dataclass
class IntOrString:
    I: Union[int, None] = None
    S: Union[str, None] = None

def funny_sum(xs: List[IntOrString]) -> int | Any:
    if not xs:
        return 0
    
    head, *tail = xs

    match head:
        case IntOrString(I=i) if i is not None:
            return i + funny_sum(tail)
        case IntOrString(S=s) if s is not None:
            return len(s) + funny_sum(tail)

xs: List[IntOrString] = [IntOrString(I=1), IntOrString(S="hello"), IntOrString(I=3), IntOrString(S="world")]
# Output will be 1 + 5 + 3 + 5 = 14
result = funny_sum(xs)

Error

def funny_sum(xs: List[IntOrString]) -> int: { ... }

In Swift, enums support pattern matching, and this code would work fine. Does this mean that data classes in Python do not support pattern matching?

I appreciate any advice on resolving this issue with the return type declaration in Python.

Edit:

Refer to this question for a similar approach that results in the same error: Handling Python Type Checker Errors with Return Type Declarations.


Solution

  • The reason the type checker warns about the function possibly returning None is because not all patterns are exhausted.

    If only (exactly) 1 of the fields of head is None, then one of the cases is bound to be matched. However, what happend if both I and S are None?

    In that case, non of the cases will match and the function will return None.

    Since Python's dataclass doesn't allow defining mutually exclusive types, you have 2 options with this.

    Either add a default case which should throw an error or you can change the IntOrString to have a single field of type str | int.

    Option 1 - Adding a default case

    def funny_sum(xs: List[IntOrString]) -> int | Any:
        if not xs:
            return 0
        
        head, *tail = xs
    
        match head:
            case IntOrString(I=i) if i is not None:
                return i + funny_sum(tail)
            case IntOrString(S=s) if s is not None:
                return len(s) + funny_sum(tail)
            case _:
                raise ValueError("Either I or S must be set!")
    
    xs: List[IntOrString] = [IntOrString(I=1), IntOrString(S="hello"), IntOrString(I=3), IntOrString(S="world")]
    # Output will be 1 + 5 + 3 + 5 = 14
    result = funny_sum(xs)
    

    Option 2 - Modifying the IntOrString type

    from dataclasses import dataclass
    from typing import Any, Union, List
    
    @dataclass
    class IntOrString:
        V: Union[int, str]
    
    def funny_sum(xs: List[IntOrString]) -> int | Any:
        if not xs:
            return 0
        
        head, *tail = xs
    
        match head.V:
            case int() as i:
                return i + funny_sum(tail)
            case str() as s:
                return len(s) + funny_sum(tail)
    
    xs: List[IntOrString] = [IntOrString(V=1), IntOrString(V="hello"), IntOrString(V=3), IntOrString(V="world")]
    # Output will be 1 + 5 + 3 + 5 = 14
    result = funny_sum(xs)
    

    Option 3 - Forcing the type checker

    You can force the type-checker to ignore the inferred type and instead use the type you tell it.

    def funny_sum(xs: List[IntOrString]) -> int:  # pyright: ignore
        ...