Question: The following script: from collections.abc import Iterable, Iterator class A(Iterable): _list: list[int] def __init__(self, *args: int) -> None: self._list = list(args) def __iter__(self) -> Iterator[int]: return iter(self._list) a = A(1, 2, 3) for i in a: reveal_type(i) for s, j in zip("abc", a): reveal_type(j)  yields the following mypy output: $ mypy test.py test.py:17: note: Revealed type is "builtins.int" test.py:20: note: Revealed type is "Any" Success: no issues found in 1 source file  Why is the type Any when iterating on zip, but not on the object directly? Note, subclassing class A(Iterable[int]) does allow for correct type resolution, but that's not the question here ;)

Answer: Comparing for loop and zip object is not an apples-to-apples comparison. For loop How is for i in X checked? At least in current mypy source, for loop is checked by looking up __iter__ signature on the type of X and using its return type. So it doesn't look at your Iterable inheritance, it finds the nearest __iter__ in MRO which happens to be the one you provided. Why is it implemented this way? For loop is a language native construct, defined at the syntactical level - in some sense it's more fundamental than existence of some protocol types encapsulating iteration behaviour. zip Now, zip is just a class. It defines its own __iter__ method, so for a,b in zip(A, B) will be checked according to its return type. This class is generic in iterator type, supporting up to 5 distinct iterables and falling back to general case for 6 and more. Here's what it looks like: typeshed permalink. There is no black magic involved. I'll quote the relevant part: class zip(Iterator[_T_co]): ... @overload def __new__(cls, iter1: Iterable[_T1], /, *, strict: bool = ...) -> zip[tuple[_T1]]: ... @overload def __new__(cls, iter1: Iterable[_T1], iter2: Iterable[_T2], /, *, strict: bool = ...) -> zip[tuple[_T1, _T2]]: ... .... def __iter__(self) -> Self: ... def __next__(self) -> _T_co: ...  (there are version-dependent branches and more overloads, I'll omit them here for brewity) You hit the two-arg overload. It looks for _T1 and _T2 by "solving" the following equation: Iterable[_T2] = A  Here's the culprit: you declare that A is an Iterable[Any]. Omitting generic type vars is fully equivalent to providing Any instead. There is no room for type checker inference. So mypy happily says "wow, I already know that A <: Iterable[Any], hence _T2 = Any, next". It does not touch the MRO and does not look into Iterable semantics at all: it just wants to know what type var should parameterize it. At this point, mypy already knows that A is a subtype of Iterable[Any] - you told it so! Even if your class doesn't contain __iter__ at all, mypy would have already reported that at definition, no need to repeat here. It would be wasteful to recheck protocol conformance at every usage site, and would also produce a bunch of undesired errors. Imagine that, for some strange reason, you have the class as follows: class B(Iterable[str]): def __iter__(self) -> Iterator[int]: ... # type: ignore[override]  Oops. This makes no sense here, but may happen in real life for more complex protocols. If mypy were to check __iter__ everywhere, you'd have to # type: ignore all places where B is used as Iterable[str]. Structural subtyping Side note: Iterable quacks like a typing.Protocol. If you do not inherit from it at all, your class still can be a subtype of Iterable - that's called structural subtyping. It will even pass assert issubclass(A, Iterable)! If you don't declare implementation explicitly, type checkers should infer the corresponding type as precisely as possible. So your case written without explicit Iterable inheritance would produce the desired outcome. General note I do understand your problem with this. Actually I'm also not sure that this approach is fully justified: probably for loop should also treat the iterable as Iterable, solving the same equation? But that's how the implementation currently behaves, and both approaches are equally valid IMO.