Skip to main content

Non-overlapping type comparisons and Python type checkers

  • Posted

Why do type checkers allow you to check if a str is a member of a list of int?

I made a mistake in Python today where I checked if a collection of one type contained an element of a different type. Here’s a minimal example:

numbers = [1, 2, 3, 4, 5]

print("one" in numbers)  # False

This is trivially false, right? A list of integers can’t contain a string, and I was surprised my type checker (ty) didn’t warn me. Then I did some reading, and I realised this isn’t quite as trivial as I thought.

When you use the in keyword, you go through several magic methods :

The types int and str include their subclasses, which can override these magic methods. Usually they do the “obvious” thing and cross-type comparisons will report False – but it’s theoretically possible.

Cross-type comparisons are usually a mistake, and some type checkers will flag it, but not by default – mypy, Pylance and Pyright only flag it in strict mode, and it’s not yet supported in ty. It’s possible to write Python where this is the correct and desired behaviour, however confusing it might appear.

Counterexamples

I wrote a couple of simple programs where I check if a str is in a list[int] and the membership test returns True. I wouldn’t write anything this confusing in a real codebase, but I found it helpful to understand these magic methods.

Here’s a custom collection that overrides __contains__:

WORD_MAP = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5}


class NumberList(list):
    def __contains__(self, item: object) -> bool:
        # Check if `item` is a number (for example, `1 in numbers`)
        if isinstance(item, int) and super().__contains__(item):
            return True

        # Check if `item` is a string (for example, `"one" in numbers`)
        if isinstance(item, str) and item in WORD_MAP:
            return super().__contains__(WORD_MAP[item])

        return False


numbers: list[int] = NumberList([1, 2, 3, 4, 5])

print(1 in numbers)      # True
print("one" in numbers)  # True

print(6 in numbers)      # False
print("six" in numbers)  # False

Here’s another approach, where I subclass int and override the __eq__ method:

NUMBER_MAP = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five"}


class Number(int):
    def __eq__(self, other: object) -> bool:
        # Check if `other` is a number (for example, x == 1)
        if isinstance(other, int) and super().__eq__(other):
            return True

        # Check if `other` is a string (for example, x == "one")
        for numeral, word in NUMBER_MAP.items():
            if super().__eq__(numeral) and other == word:
                return True

        return False


numbers = [Number(1), Number(2), Number(3), Number(4), Number(5)]

print(1 in numbers)      # True
print("one" in numbers)  # True

print(6 in numbers)      # False
print("six" in numbers)  # False