Non-overlapping type comparisons and Python type checkers
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) # FalseThis 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 :
- For
item in collection, Python calls__contains__(self, item)on the collection. - If an object doesn’t define
__contains__but does define__iter__, Python iterates through the collection and looks for an elementxwherex is itemorx == itemisTrue. - When you call
x == y, Python calls__eq__(self, y)onx.
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) # FalseHere’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