2025, Dec 13 21:00

Type-Safe __contains__ in Pyright Strict Mode: Replace all(isinstance) with a TypeIs Iterable[T] Guard

Learn why Pyright strict mode won’t narrow Iterable[str] with all(isinstance) and how to add a TypeIs type guard for a safe __contains__ pattern. Avoid Unknowns.

Working with Pyright in strict mode often exposes gaps between runtime checks and what a static analyzer can actually prove. A common case: verifying that a value annotated as object is an Iterable[str] and expecting the type checker to follow along after an isinstance guard. It doesn’t. Here’s why, and how to address it cleanly.

The problem

You need to implement __contains__ on a MutableSequence subclass, which fixes the method signature to accept value: object. At runtime, you want to accept any Iterable[str], normalize it, and compare it against your internal data. The straightforward approach looks like this:

from typing import Iterable

class Holder:
    def __init__(self, payload: Iterable[str]) -> None:
        self.payload = payload

    def __contains__(self, probe: object) -> bool:
        if isinstance(probe, Iterable) and all(isinstance(tok, str) for tok in probe):
            merged = "".join(probe).upper()
            return merged in "".join(self.payload)
        return False

At runtime this works. Under Pyright strict mode, however, the variable inside the generator, tok, remains of type Unknown, and the checker doesn’t refine probe to Iterable[str] inside the if block. Manually looping, for tok in probe, doesn’t help either; the element type is still unknown.

Why the isinstance + all(...) check doesn’t narrow

Pyright does not treat all(isinstance(...)) as a type guard. Unlike built-ins such as filter that can be modeled through stubs, all would require special-case logic in the type checker and assumptions about the iterable’s semantics. The maintainers summarize it as follows:

The type checker would not only need to hard code knowledge of all but also make assumptions about the semantics of the iterable expression it is acting upon.

Support would need to be added specifically for all(isinstance(a, b) for a in [x, y]). This specific expression form is rarely used, so it wouldn’t make sense to add the custom logic to support it.

In short, all(...) won’t narrow your probe to Iterable[str] in Pyright, and the element variable tok will remain Unknown in strict mode.

The fix: a dedicated type guard

The recommended approach is to introduce a small, reusable type guard that expresses “this is an Iterable[T] with elements of a given type”. Then gate the body of __contains__ behind it. This keeps the signature value: object intact while giving Pyright the signal it understands.

from typing import Iterable, TypeIs

# Reusable guard that checks both iterability and element type

def ensure_iterable_of[T](thing, kind: type[T] = object) -> TypeIs[Iterable[T]]:
    return isinstance(thing, Iterable) and all(isinstance(elem, kind) for elem in thing)

class Holder:
    def __init__(self, payload: Iterable[str]) -> None:
        self.payload = payload

    def __contains__(self, probe: object) -> bool:
        if ensure_iterable_of(probe, str):
            normalized = "".join(probe).upper()
            return normalized in "".join(self.payload)
        return False

This pattern narrows probe to Iterable[str] inside the if block. If you prefer not to introduce a helper, a cast after the check is an alternative. If strict mode still flags the generator variable as unknown during the all(...) call, you can either add a targeted ignore for reportUnknownArgumentType or split the guard into two steps so the iteration itself is over an Iterable[Any], which avoids the unknown-argument complaint.

from typing import Any, Iterable, TypeIs

# Step 1: recognize "iterable of any"

def seems_iterable(source: object) -> TypeIs[Iterable[Any]]:
    return isinstance(source, Iterable)

# Step 2: refine to a specific element type, leveraging the first guard

def ensure_iterable_of_type[T](source: object, kind: type[T] = object) -> TypeIs[Iterable[T]]:
    return seems_iterable(source) and all(isinstance(unit, kind) for unit in source)

A note on runtime behavior

Besides typing, be mindful that some iterables—generators or file objects—are single-pass. The all(isinstance(...)) check will consume them, so a subsequent join would see an empty stream. This is a runtime caveat independent of typing, but it matters when you validate and then re-iterate the same object.

Why this matters

Static type narrowing in Python hinges on patterns a checker can recognize. Guards like isinstance(var, SomeType) on a variable are straightforward; generator expressions buried inside all(...) are not. Expecting all(...) to refine types leads to brittle assumptions and Unknown types leaking into your code paths. A small, explicit TypeIs-based guard expresses intent clearly, narrows precisely where needed, and keeps strict mode useful instead of noisy.

Conclusion

Don’t rely on all(isinstance(...)) to refine Iterable element types in Pyright strict mode. It’s not supported for narrowing, and iterating “manually” doesn’t change that. Introduce a simple type guard that asserts “iterable of T”, or cast after your runtime checks if that fits your style. Keep an eye on single-pass iterables if you both validate and consume them. With these adjustments, you can keep the required object-typed interface of __contains__ while maintaining clean, precise types inside the method body.