What problem does `typing.TypeGuard` solve that a plain `isinstance` check cannot?
answer
- Runtime classes carry no parameters
- isinstance against list[str] is an error
- Invariance blocks the assignment too
- You write the loop, annotate the conclusion
- TypeGuard imposes no subtype relationship
basics
~20 sisinstance can only test a runtime class, so it proves a value is a list but never a list of strings. A TypeGuard predicate runs that element check and hands the checker the parameterized conclusion.
solid answer
~40 s`isinstance` sees only the runtime class; generic parameters are erased, and `isinstance(x, list[str])` is an outright `TypeError` — argument 2 cannot be a parameterized generic. So no built-in check can take a `list[object]` to a `list[str]`, and a checker will not do it either, because `list` is invariant and `list[str]` is not assignable to `list[object]`. `typing.TypeGuard[list[str]]` is the escape hatch: you write the element loop once, and the annotation tells the checker what a True result means. The same trick covers conclusions `isinstance` cannot express at all — a decoded `dict[str, object]` that matches a `TypedDict` shape, a tuple of a fixed length, a string that matched a pattern. The price is that the checker trusts your loop without verifying it.
code
python · 4 linestry:
isinstance(["dark-mode"], list[str])
except TypeError as exc:
print("runtime cannot see the parameter:", exc)go deeper
Remember that isinstance tests only a class, and that isinstance(x, list[str]) raises TypeError. That limit is the reason custom type predicates exist at all.
Explain both halves: parameters are erased at runtime, and list is invariant so list[str] is not assignable to list[object]. Then show the TypeGuard predicate that bridges the gap.
Judge when a predicate earns its keep — repeated non-trivial checks and decoding boundaries yes, one-line isinstance wrappers no — and account for its linear cost and its unverified trust.
Frame the codebase-wide choice: how much untrusted data is narrowed by hand-written predicates versus validated once by a schema layer at the edge, and who owns keeping those assertions true as types evolve.
## Where the built-in check stops `isinstance(x, C)` asks a single question: is this object an instance of that class (or of a subclass, or of something registered with that abstract base class). It is a runtime check about a runtime class, and Python's generic parameters are not runtime data. `list[str]` and `list[int]` are the same class at runtime — `list` — with the parameter living only in the annotation. The language is explicit about it rather than silently wrong: ```python >>> isinstance(["dark-mode"], list[str]) TypeError: isinstance() argument 2 cannot be a parameterized generic ``` So if a function is handed a `list[object]` decoded from a configuration payload and needs a `list[str]` for the rest of the body, no built-in check can get it there. A type checker cannot bridge the gap either, and this is the part candidates usually miss: even after you have proved every element is a `str`, `list[str]` is not assignable to `list[object]`, because `list` is *invariant* — a `list[str]` cannot stand in for a `list[object]`, since something holding the wider type would be entitled to append an `int` to it. The narrowing you want is not a subtype relationship at all. ## What TypeGuard adds `typing.TypeGuard[T]` (Python 3.10, PEP 647) lets a function you wrote perform an arbitrary check and then tell the checker the conclusion: ```python from typing import TypeGuard def is_str_list(vals: list[object]) -> TypeGuard[list[str]]: return all(isinstance(v, str) for v in vals) ``` Inside `if is_str_list(flags):` the checker treats `flags` as `list[str]`, so `str.join`, `.upper()` on the elements and anything else `str`-shaped type-checks. `TypeGuard` deliberately imposes **no** relationship between the parameter type and the narrowed type, which is exactly what the invariant-container case needs. Its two-way sibling `typing.TypeIs` (3.13, PEP 742) *does* require the narrowed type to be assignable to the declared parameter type, so `TypeIs[list[str]]` on a `list[object]` parameter is rejected — the invariance that motivated the whole exercise blocks it. ## The other conclusions isinstance cannot express Parameterized containers are the headline case, but the same limit shows up wherever the interesting property is not a class: * A decoded `dict[str, object]` that matches a `TypedDict` shape: the required keys are present with the right value types. A `TypedDict` has no runtime class of its own to test. * A tuple of exactly three elements, so it can be treated as a fixed-length structure. * A `str` that matched a validation pattern and can therefore be treated as a distinct alias for identifiers. * A union member selected by a discriminator field rather than by class, common with payloads that carry a kind or event field. In every one of these, the check itself is easy to write in ordinary Python; what was missing was a way to communicate its result to the checker. ## When you do not need it A predicate is not an improvement over an inline check that the checker already understands. `if isinstance(x, str):` narrows perfectly well on its own, and so do `x is None`, `assert isinstance(...)`, an early return, and `type(x) is C`. Wrapping any of those in a one-line predicate adds a function call, a name, and an unverified assertion in exchange for nothing. Reach for a predicate when the test is non-trivial, when it is repeated across modules, or when the conclusion cannot be spelled as a class. ## The cost you are accepting The checker does not verify the loop inside the predicate. It sees the signature and believes it. That makes a predicate the same category of construct as `typing.cast`: a place where you, not the tool, are responsible for soundness. Two practical consequences follow. Keep the body total — every path that could be false must actually return False, including the empty-container case, whose truth is usually vacuous and usually correct. And remember it is real work: `all(isinstance(v, str) for v in vals)` is O(n) on every call, so run it once at the decoding boundary and pass the narrowed value inward rather than re-asking in a loop. ## Version notes `typing.TypeGuard` requires Python 3.10 or newer; `typing.TypeIs` requires 3.13 or newer. The `isinstance` restriction on parameterized generics is not new and has not changed in 3.14 — it is a consequence of parameters being erased at runtime, not a temporary limitation.
- Why can a checker not simply infer list[str] once every element has been shown to be a str?Because `list` is invariant. A `list[str]` is not usable where a `list[object]` is expected, since the wider type permits appending a non-string. Proving the current contents are strings says nothing about future mutations, so the narrowing is unsound in general — which is precisely why it has to be an assertion you make explicitly with TypeGuard rather than something inferred.
- Could you use typing.TypeIs instead for the list[object] to list[str] case?No. `TypeIs[T]` requires `T` to be assignable to the declared parameter type, and invariance means `list[str]` is not assignable to `list[object]`, so a checker rejects the signature. That case is exactly the one `TypeGuard` exists for; `TypeIs` fits genuine is-this-one-of-these tests such as narrowing an `int | str` to `int`.
- What is the runtime cost of a predicate like this on a large payload?Linear in the size of the structure, on every call, and completely invisible to the checker. Run the predicate once where data enters — after decoding — and pass the narrowed value down the call chain. Re-testing the same object inside a loop turns an O(n) validation into O(n squared) work for no additional type safety.
saying these in an interview costs you the question
- Claiming isinstance can check list[str] directly
- Thinking generic parameters survive to runtime
- Missing that list invariance blocks the narrowing
- Wrapping a plain isinstance in a needless predicate
- Assuming the checker validates the predicate loop