Search code examples
pythontyping

typing for rare case fallback None value


Trying to avoid typing issues I often run into the same problem.

E.g. I have a function x that very rarily returns value None, all other times it returns int.


def x(i: int) -> Union[int, None]:
    if i == 0:
        return
    return i

def test(i: int):
    a = x(i)
    # typing issue: *= not supported for types int | None and int
    a *= 25

x used very often in the codebase and most of the time i was already checked a hundred times that x(i) will indeed return int and not None. Using it as int right away creates typing warnings - e.g. you can't multiply possible None value.

What's best practice for that case?

Ideas I considered:

  1. There is no real sense to check it for None with if a is None: return as it's already known.
  2. a *= 25 # type: ignore will make a an Unknown type.
  3. a = x(i) # type: int will make the warning go away. But will create a new warning "int | None cannot be assigned to int"
  4. a = cast(int, x(i)), haven't tested it much yet.

I usually end up changing return type of x to just int, adding ignore in return # type: ignore and mention in the docstring that it can return None, it helps avoiding contaminating the entire codebase with type warnings. Is this the best approach?

def x(i: int) -> int:
    """might also return `None`"""
    if i == 0:
        return # type: ignore
    return i

Solution

  • This might be a case where an exception is better than a return statement you never expect to be reached.

    def x(i: int) -> int:
        if i == 0:
            raise ValueError("didn't expect i==0")
        return i
    
    def test(i: int):
        try:
            a = x(i)
        except ValueError:
            pass
    
        a *= 25
    

    Code that is confident it has sufficiently validated the argument to x can omit the try statement.

    Statically speaking, this is accurate: if x returns, it is guaranteed to return an int. (Whether it will return is another question.)


    Ideally, you could define a refinement type like NonZeroInt, and turn i == 0 into a type error, rather than a value error.

    # Made-up special form RefinementType obeys
    #
    #  isinstance(x, RefinementType[T, p]) == isinstance(x, T) and p(x)
    NonZeroInt = RefinementType[int, lambda x: x != 0]
    
    def x(i: NonZeroInt) -> int:
        return i
    
    x(0)  # error: Argument 1 to "x" has incompatible type "int"; expected "NonZeroInt"  [arg-type]
    
    i: int = 0
    x(i)  # same error
    
    j: NonZeroInt = 0  #  error: Incompatible types in assignment (expression has type "int", variable has type "NonZeroInt")  [assignment]
    
    x(j)  # OK
    
    k: NonZeroInt = 3  # OK
    x(k)  # OK