What does a function annotated `-> typing.TypeGuard[list[str]]` return at runtime?
answer
- Annotations never run anything
- The body still returns True or False
- The checker takes your word for it
- Same trust level as a cast
- First positional parameter gets narrowed
basics
~10 sA plain bool. TypeGuard[list[str]] is a static-only annotation: the function returns True or False, the interpreter enforces nothing, and a type checker simply trusts that boolean to narrow the argument inside the if branch.
solid answer
~40 sAt runtime nothing special happens — the call returns an ordinary `bool`, and `typing.TypeGuard[list[str]]` is a message aimed only at the static checker. The checker requires the body to return a boolean and reads the annotation as: when this call returns True, treat the first positional argument as `list[str]` for the rest of that branch. The trust is unconditional; no tool ever looks inside the predicate body, so a predicate that returns True without really checking anything poisons every inference downstream, exactly like `typing.cast`. `typing.TypeGuard` arrived in Python 3.10 and its two-way sibling `typing.TypeIs` in 3.13, and neither has any runtime effect: if you want a real guarantee, the body still has to do the `isinstance` work itself.
code
python · 8 linesfrom typing import TypeGuard
def is_str_list(vals: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(v, str) for v in vals)
print(is_str_list(["beta-checkout", "dark-mode"]))
print(type(is_str_list(["dark-mode", 17])))
print(is_str_list.__annotations__["return"])go deeper
Recall the one-liner: the function returns a plain bool and the annotation only talks to the type checker. Be ready to say that Python raises nothing if the predicate is wrong.
Explain the mechanics: the checker requires a boolean body, narrows the first positional parameter on the True branch, and never inspects the body — the same trust it gives typing.cast.
Show the operational consequence: an unverified predicate is a hand-written soundness hole, so it needs unit tests for both branches, must stay cheap because its cost is invisible to the checker, and should be reviewed whenever the asserted type changes.
Own the policy question of where hand-written predicates are allowed at all — how many such trusted assertions a codebase can carry, whether they live at decoding boundaries only, and what review or test standard keeps them from becoming silent casts.
## A predicate is a normal function with a promise attached A *type predicate* is an ordinary Python function that answers a yes/no question about a value: is this list made only of strings, is this decoded payload shaped like a mapping of service names to their dependencies. Written without any typing machinery it is just: ```python def is_str_list(vals): return all(isinstance(v, str) for v in vals) ``` A static type checker cannot learn anything from that. It sees a function returning `bool`, and after `if is_str_list(flags):` the variable `flags` still has whatever type it was declared with. `typing.TypeGuard[list[str]]` as the return annotation is the mechanism for saying: *this boolean is not just a boolean — a True result means the first positional argument is a `list[str]`.* ## What actually happens when you call it Nothing new. The interpreter does not read the annotation, does not verify the returned value, and does not convert anything. `is_str_list(["a"])` evaluates the body and returns `True`; `type(...)` on the result is `bool`. Checkers additionally require every `return` in the body to produce a boolean-compatible value, but that is a static rule, not an interpreter rule — CPython will happily let such a function return a string, and only the checker complains. The annotation object itself does exist: reading `is_str_list.__annotations__["return"]` gives back `typing.TypeGuard[list[str]]`. From Python 3.14 annotations are evaluated lazily (PEP 649), so that subscripted form is not even constructed at `def` time — it is built on first access to `__annotations__`, or fetched in other formats through the `annotationlib` module. That laziness changes when the expression is evaluated; it does not give the annotation any runtime power it did not have before. ## The trust is unconditional, and that is the whole design This is the point interviewers are usually probing. A checker verifies most of your code, but it does **not** verify a predicate body. `TypeGuard` and its 3.13 sibling `TypeIs` are, from the checker's point of view, a `typing.cast` with a nicer face: you assert the conclusion and the tool believes you. ```python from typing import TypeGuard def is_str_list(vals: list[object]) -> TypeGuard[list[str]]: return True # a lie no checker can catch flags: list[object] = [17, 42] if is_str_list(flags): print([name.upper() for name in flags]) # AttributeError at runtime ``` The checker accepts `name.upper()` because it was told the elements are strings. The failure surfaces at runtime, far from the lie, as an `AttributeError`. That is why a predicate body should be short, total, and boring: it is the one place where the type system's guarantees rest entirely on you being right. ## The narrowed parameter is the first positional one The annotation says nothing about *which* argument it describes; the rule is fixed. Checkers narrow the first positional parameter of the predicate, or the first one after `self`/`cls` for a method. Extra parameters may influence the answer but are never narrowed by it, and a predicate declared with no positional parameter at all is rejected — there is nothing for the annotation to be about. ## Why this matters in practice Because the runtime is uninvolved, three habits follow. First, keep the check real: the `isinstance` loop, the length test, the key inspection must actually be there, or the annotation is a lie. Second, test the predicate like any other function, with values that should pass and near-miss values that should fail; unit tests are the only thing standing behind the assertion. Third, remember the cost is real even though the type is not: a predicate that walks a large decoded structure runs that walk on every call, and the checker has no idea how expensive its truth was to establish. ## The version story in one line `typing.TypeGuard` landed in Python 3.10 (PEP 647), `typing.TypeIs` in 3.13 (PEP 742); on 3.9 and older neither exists in the standard library. Python 3.14 changed only when annotations are evaluated (PEP 649), not what they mean — both remain purely static, and both still return a plain `bool` at runtime.
- What happens if a TypeGuard predicate returns True for a value that is not really of that type?At runtime, nothing — the call just returns True. Statically it is worse than nothing: the checker narrows the variable and validates the whole branch against a type the value does not have, so the mistake surfaces far away, typically as an AttributeError on the first attribute access. Treat the body like a `typing.cast`: it is an assertion you own, and no tool will check it for you.
- Which parameter does a checker narrow when the predicate takes several arguments?The first positional parameter, or the first after `self` or `cls` in a method. Later parameters can influence the boolean the predicate returns but are never narrowed by it, and a predicate with no positional parameter is rejected outright, because the annotation would have nothing to describe.
- Does anything change in Python 3.14, where annotations are evaluated lazily?Only the timing. Under PEP 649 the `TypeGuard[...]` expression is not built at `def` time; it is constructed when something reads `__annotations__`, and other formats are reachable through `annotationlib`. The narrowing itself was always a static-checker behaviour, so laziness neither adds nor removes any runtime effect.
It is a sticker you put on a box saying CONTAINS ONLY STRINGS. The warehouse software believes the sticker and routes the box accordingly; nobody opens the box to check, so a wrong sticker is discovered only when something downstream reaches inside.
saying these in an interview costs you the question
- Claiming TypeGuard validates the argument at runtime
- Thinking the predicate returns the narrowed value, not a bool
- Believing a checker verifies the predicate body
- Expecting an exception when a predicate lies
- Saying the annotation narrows the last argument