pytype icon indicating copy to clipboard operation
pytype copied to clipboard

Sentinel pattern fails to narrow type

Open eltoder opened this issue 2 years ago • 3 comments

Apologies if this is a know issue. I could not find it in the FAQ or in github issues. The common "sentinel value" pattern fails to narrow type and produces errors. A reduced example:

from typing import Iterable

MISSING = object()

def last(xs: Iterable[int], default: int = 0) -> int:
    res = MISSING
    for x in xs:
        res = x
    return default if res is MISSING else res

This produces an error due to res having a union type even after the if

File "/home/elt/code/pytype-test/proj/sentinel.py", line 9, in last: bad option 'object' in return type [bad-return-type]
           Expected: int
  Actually returned: Union[int, object]

Is there a way to annotate MISSING or res to help pytype infer the correct type? In principle, MISSING here acts as a singleton value similar to None, so if it was possible to annotate res as

res: int | Literal[MISSING] = MISSING

or something along these lines, it would probably work. Unfortunately, pytype rejects Literal[MISSING].

eltoder avatar Feb 23 '23 15:02 eltoder

I found that the recommended way to make sentinels that work with type checking is to use an Enum^1:

from enum import Enum
from typing import Iterable

class Sentinel(Enum):
    MISSING = 0

MISSING = Sentinel.MISSING

def last(xs: Iterable[int], default: int = 0) -> int:
    res: int | Sentinel = MISSING
    for x in xs:
        res = x
    return default if res is MISSING else res

This indeed works in mypy, but pytype generates a similar error:

$ pytype --use-enum-overlay proj/sentinel.py
...
File "/home/elt/code/pytype-test/proj/sentinel.py", line 13, in last: bad option 'Sentinel' in return type [bad-return-type]           Expected: int
  Actually returned: Union[Sentinel, int]

This is a lot more verbose than using Literal, but literals are not supported in mypy either, and the discussion is stalled^2.

eltoder avatar Feb 26 '23 02:02 eltoder

Looks like the problem is with the is check. Both of these work with pytype:

from typing import Iterable

class _Missing:
  pass

MISSING = _Missing()

def last(xs: Iterable[int], default: int = 0) -> int:
    res = MISSING
    for x in xs:
        res = x
    return default if isinstance(res, _Missing) else res
from enum import Enum
from typing import Iterable

class Sentinel(Enum):
    MISSING = 0

MISSING = Sentinel.MISSING

def last(xs: Iterable[int], default: int = 0) -> int:
    res: int | Sentinel = MISSING
    for x in xs:
        res = x
    return default if isinstance(res, Sentinel) else res

rchen152 avatar Mar 03 '23 00:03 rchen152

Yes, the trick in this patten is to convince the type checker that the is check is sufficient. (is is much faster than isinstance at runtime.) With the enum approach, the enum must have only one element so that x is MISSING is equivalent to isinstance(x, Enum).

eltoder avatar Mar 03 '23 03:03 eltoder