Static type checkers commonly employ a technique called type narrowing to determine a more precise type of an expression within a program’s code flow. When it is applied within a block based on a conditional statement, the conditional expression is sometimes called a type guard. Python checkers already support several: is None, truthiness, isinstance, and comparison against a Literal.
Then there is the case the built-ins cannot reach.
def is_str_list(val: list[object]) -> bool:
return all(isinstance(x, str) for x in val)
def func1(val: list[object]):
if is_str_list(val):
print(" ".join(val)) # error: invalid typePEP 647 is precise about what went wrong: this code is correct, but a type checker will report a type error, because the value passed to join is understood to be of type list[object] and the checker does not have enough information to statically verify that the type is list[str] at this point. The knowledge exists. It just has nowhere to live in the signature, since bool says nothing about which branch learned what.
The idea
Narrowing is flow-sensitive typing: the same name has different types at different program points, which is the type-level counterpart of the renaming that SSA form performs on values.
TypeGuardandTypeIsboth let a function participate in that analysis, but they promise different things.TypeGuardasserts a type for the positive branch only and is deliberately allowed to name a type that is not narrower than the input.TypeIsasserts a set-theoretic intersection in both branches, and pays for that strength with a hard assignability requirement and invariance.
TypeGuard: a promise about the if branch
TypeGuard is a special form accepting a single type argument, used to annotate the return type of a user-defined type guard function. Return statements within such a function should return bool values, and checkers should verify that all return paths do. It is also valid as the return type of a callable, where it is treated as a subtype of bool, so Callable[..., TypeGuard[int]] is assignable to Callable[..., bool].
The narrowing target is positional. Type checkers should assume that narrowing applies to the expression passed as the first positional argument, and if the function accepts more arguments, no narrowing is applied to those. For an instance or class method, the first positional argument maps to the second parameter, after self or cls. That leaves room for the useful two-argument shape, such as is_str_list(val, allow_empty), where only val is narrowed.
Change the annotation and the earlier example type-checks: the return type becomes TypeGuard[list[str]], which promises not merely that the return value is boolean, but that a true indicates the input to the function was of the specified type.
The else branch learns nothing
Some built-in type guards provide narrowing for both positive and negative tests. If
xis a union ofNoneand something else,x is Nonenarrows toNonein the positive case and the other type in the negative case. User-defined type guards apply narrowing only in the positive case, and the type is not narrowed in the negative case. GivenOneOrTwoStrs = tuple[str] | tuple[str, str], aTypeGuardfor the two-element case narrows totuple[str, str]inside theifand leaves the full union inside theelse. Writingif not is_two_element_tuple(val)does not change this: theelsegetstuple[str, str]and theifgets the unnarrowed union. Exhaustiveness reasoning built on aTypeGuardwill quietly fail to eliminate the case it just tested.
The asymmetry is not an oversight. The return type of a user-defined type guard will normally refer to a type that is strictly narrower than the type of the first argument, but it is not required to be. That is what allows the motivating example at all, since list[str] is not assignable to list[object]. TypeGuard buys expressiveness by refusing to make a claim strong enough to invert.
TypeIs: an intersection in both directions
TypeIs is similar in usage, behavior, and runtime implementation, and a function annotated as returning one is called a type narrowing function. The same positional rules apply. What differs is the guarantee.
The return type R must be assignable to the input type I, and the checker should emit an error otherwise, so def is_str(x: int) -> TypeIs[str] is rejected outright. Given that, the specification states the semantics in set-theoretic terms: for an argument of pre-narrowed type A, the positive branch narrows to the intersection of A and R, and the negative branch narrows to the intersection of A and the complement of R.
Two consequences follow. Narrowing applies in both the positive and the negative case, so is_str on a str | int gives str in one branch and int in the other, which is what people expect the first time and rarely get from TypeGuard. And the final narrowed type may be narrower than R, because of the constraints of the argument’s previously known type: an isawaitable returning TypeIs[Awaitable[Any]] applied to an Awaitable[int] | int yields Awaitable[int], not Awaitable[Any].
Why TypeIs must be invariant
Unlike
TypeGuard,TypeIsis invariant in its argument type:TypeIs[B]is not a subtype ofTypeIs[A]even whenBis a subtype ofA. The specification’s example is worth walking. A function takes anint | strplus a callable that accepts anobjectand returnsTypeIs[int], adds one in the true branch and concatenates a string in the false branch. Passis_bool, which returnsTypeIs[bool], and note thatboolis a subtype ofint. Call it with1. The narrower returns false, because1is not abool, so the else branch runs and tries"hello " + xwithxbound to1. The code fails at runtime. If the call were allowed, type checkers would fail to detect this error. Covariance is unsound precisely becauseTypeIsmakes a claim about the negative branch, and a narrower test produces more false results than the wider one it was substituted for.
Where the guarantee stops
The intersection semantics are aspirational. In practice, the theoretic types for strict type guards cannot be expressed precisely in the Python type system, and type checkers should fall back on practical approximations of these types. The guidance offered is a rule of thumb rather than an algorithm: a checker should use the same narrowing logic as, and get results consistent with, its handling of isinstance(). That leaves real room for two conforming checkers to disagree about a branch, and the specification says so while noting the guidance allows for changes and improvements if the type system is extended in the future.
The other silent failure is upstream. Both forms depend on a function body that actually verifies what the annotation claims, and nothing checks that. TypeGuard[list[str]] on a function that returns True unconditionally is a well-typed lie, which puts these forms in the same family as a runtime-checkable protocol check: a narrowing whose strength rests entirely on a promise the tooling cannot audit.
Generic type guards work as expected, so is_two_element_tuple[T](val: tuple[T, ...]) -> TypeGuard[tuple[T, T]] narrows a tuple[str, ...] to tuple[str, str], and the type variable carries through the narrowing.
Related Notes
- Intermediate Representations and SSA - the same flow-sensitive renaming, applied to values instead of types
- Set Theory Basics - the intersection and complement
TypeIsis specified in terms of - Any, object, and Never - what an empty narrowing result actually is
- Runtime-Checkable Protocols and Their Limits - the other place a check licenses more than it proves
- Erasure at Runtime and Type Guards - the same feature in a language whose predicate signatures came first
- Discriminated Unions and Exhaustiveness - narrowing that does close both branches, and what it costs to set up
Sources
- “Type narrowing,” Specification for the Python type system. https://typing.readthedocs.io/en/latest/spec/narrowing.html . Supports
TypeGuardbeing a special form with a single type argument used on the return of a user-defined type guard, the requirement that all return paths returnbool, its validity as a callable return type and treatment as a subtype ofboolwith theCallable[..., TypeGuard[int]]assignability example, narrowing applying to the first positional argument only with no narrowing of additional arguments and the method offset afterselforcls, the genericis_two_element_tupleexample, the statement that the return type is normally but not necessarily strictly narrower and thelist[str]andlist[object]justification, built-in guards narrowing both branches with thex is noneexample while user-defined type guards narrow only the positive case, theOneOrTwoStrsexample including thenotform,TypeIsbeing similar in usage, behavior, and runtime implementation with functions called type narrowing functions, the requirement that the return type be assignable to the input type with an error otherwise and thedef is_str(x: int) -> TypeIs[str]rejection, the intersection and complement formulation for the positive and negative narrowed types, the statement that the theoretic types cannot be expressed precisely and checkers should fall back on practical approximations consistent withisinstance(), narrowing applying in both cases with thestr | intexample, the final narrowed type being narrower than the declared one with theisawaitableexample,TypeIsinvariance with thetakes_narrowerandis_boolwalkthrough including the runtime failure and the note that checkers would fail to detect the error. - “PEP 647 - User-Defined Type Guards,” Python Enhancement Proposals. https://peps.python.org/pep-0647/ . Supports the description of type narrowing as a technique for determining a more precise type within a program’s code flow, the term type guard for the conditional expression, the built-in guard forms, the
is_str_listexample being correct while the checker reports an error because the value is understood aslist[object], the statement that the checker lacks the information to verifylist[str]at that point, and the meaning of the changed return type as promising not merely a boolean but that a true result indicates the input was of the specified type.