pythonpattern-matchingpython-typingpyright

Handling Python Type Checker Errors with Return Type Declarations


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.

Look/read this question first: Handling Python Type Checker Errors with Return Type Declarations (fields default values = None)

Error message:

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

What about this case? I still get the same error as above(question from stack overflow), even though my classes do not contain any None.

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

class Exp:
    pass

@dataclass
class Const(Exp):
    value: int

@dataclass
class Negate(Exp):
    value: Exp

@dataclass 
class Add(Exp):
    value1: Exp
    value2: Exp

@dataclass
class Multiply(Exp):
    value1: Exp
    value2: Exp


def eval_exp_old(e: Exp) -> int | Any:
    match e:
        case Const(i):
            return i
        case Negate(e2):
            return -eval_exp_old(e2)
        case Add(e1, e2):
            return eval_exp_old(e1) + eval_exp_old(e2)
        case Multiply(e1, e2):
            return eval_exp_old(e1) * eval_exp_old(e2)
        

test_exp: Exp = Multiply(Negate(Add(Const(2), Const(2))), Const(7))
# -28
old_test = eval_exp_old(test_exp)

Error:

def eval_exp_old(e: Exp) -> int { ... }

I appreciate any advice.

Btw, check this article, also posted in accepted answer. https://discuss.python.org/t/draft-pep-sealed-decorator-for-static-typing/49206


Solution

  • What if someone inherits from Exp and passes that to eval_exp_old()?

    class NewExp(Exp): pass
    
    eval_exp_old(NewExp())  # Pass type checking, yet silently returns None
    

    Add a default case to your matching tree and throw an error in it. That way, you will get a runtime error (preferably in tests) telling you that you forgot to handle some cases:

    match e:
        case Const(i):
            return i
        # ...
        case _:
            raise ValueError(f'Value {e} of type {type(e).__name__} is not expected')
    

    Generally-speaking, what you want is called "sealed classes":

    @sealed  # This means all subclasses are known ahead of time
    class Exp:
        pass
    

    This has only ever existed in a recent PEP draft and this relevant thread on the official Python forum.