Type narrowing lets a static checker transform a broad union into more specific types after a condition. typing.TypeIs represents predicates that identify a genuine subtype and narrow both the true and false branches. It is useful when a reusable helper behaves like a type test but the rule should remain centralized and domain-focused.
This guide compares TypeIs with TypeGuard and isinstance(), explains unions, subclasses, Protocol, generics, and optional values, and shows how to avoid annotations that promise an impossible relationship.
Why a Boolean helper may lose type information
def is_string(value: object) -> bool:
return isinstance(value, str)
item: str | bytes = "text"
if is_string(item):
print(item.upper())The implementation is correct, but the signature only says that a Boolean is returned. A checker may not connect that result with the type of item.
Declaring TypeIs
from typing import TypeIs
def is_string(value: object) -> TypeIs[str]:
return isinstance(value, str)When the function returns True, the argument is narrowed to str. When it returns False, str is removed from the possible types. For str | bytes, the false branch becomes bytes.
Bidirectional narrowing
def process(value: str | bytes) -> None:
if is_string(value):
print(value.casefold())
else:
print(value.hex())This precise negative narrowing is the main practical difference from TypeGuard, which traditionally focuses on the true branch.
The subtype relationship is required
The type inside TypeIs must be compatible as a subtype of the parameter type. A predicate receiving str | bytes may identify str or bytes, but it must not claim that the value is an int.
def invalid(value: str | bytes) -> TypeIs[int]:
return isinstance(value, int)Checkers should reject this signature. The restriction is what makes exclusion in the negative branch sound.
Base classes and subclasses
from dataclasses import dataclass
from typing import TypeIs
@dataclass
class Event:
id: int
@dataclass
class ErrorEvent(Event):
message: str
def is_error(event: Event) -> TypeIs[ErrorEvent]:
return isinstance(event, ErrorEvent)The true branch sees ErrorEvent. The false branch excludes that subclass while preserving the other possibilities compatible with Event.
Discriminating a union
@dataclass
class Created:
resource_id: int
@dataclass
class Deleted:
resource_id: int
@dataclass
class Failed:
reason: str
Action = Created | Deleted | Failed
def is_failure(value: Action) -> TypeIs[Failed]:
return isinstance(value, Failed)After if is_failure(value), the negative branch contains Created | Deleted. A sequence of predicates can progressively reduce a large union.
TypeIs versus TypeGuard
TypeGuard allows more flexible narrowing. It can, for example, claim that a list[object] contains only strings even though list[str] is not a normal subtype of list[object] because lists are invariant. TypeIs requires a valid subtype relationship.
Choose TypeIs when the predicate identifies a real portion of the input type and negative narrowing matters. Choose TypeGuard for structural validation or relationships that ordinary subtyping cannot represent.
TypeIs versus isinstance
For a simple local condition, isinstance() remains clearer.
if isinstance(value, str):
...TypeIs is valuable when the test has a domain name, appears in several modules, combines multiple checks, or hides implementation details.
Predicates with extra conditions
class Response:
status: int
class SuccessResponse(Response):
data: dict[str, object]
def is_success_response(
response: Response,
) -> TypeIs[SuccessResponse]:
return (
isinstance(response, SuccessResponse)
and 200 <= response.status < 300
)Every accepted value must truly belong to the declared subtype. Extra conditions may select a smaller subset, but they must never admit an object outside the promised type.
Protocol and runtime_checkable
Protocol expresses structural contracts, but not every Protocol supports isinstance(). A runtime check can use @runtime_checkable.
from typing import Protocol, TypeIs, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
def is_closable(value: object) -> TypeIs[Closable]:
return isinstance(value, Closable)Runtime Protocol checks primarily inspect the presence of members, not a deep match of method signatures. They do not replace behavioral tests.
TypeIs with generics
from collections.abc import Sequence
from typing import TypeIs, TypeVar
T = TypeVar("T")
def is_tuple(value: Sequence[T]) -> TypeIs[tuple[T, ...]]:
return isinstance(value, tuple)The predicate preserves the generic parameter while identifying a specific implementation of the interface. The destination remains compatible with the original parameter.
Optional values
def is_not_none(value: T | None) -> TypeIs[T]:
return value is not NoneThe true branch becomes T and the false branch becomes None. A local is not None check is normally enough, but the reusable predicate can help functional APIs and shared validation code.
Filtering values
def is_integer(value: object) -> TypeIs[int]:
return type(value) is int
items: list[object] = [1, "a", True, 2]
integers = [item for item in items if is_integer(item)]Using exact type identity rejects Boolean values, because bool is a subclass of int. Decide deliberately between exact identity and isinstance().
Intersection with the known type
TypeIs does not blindly replace the existing type. The checker intersects the known type with the declared subtype. If the variable is already str | bytes and the predicate returns TypeIs[str], the positive branch becomes str. Existing compatible information is preserved in more complex cases.
Unsound predicates
def is_string(value: object) -> TypeIs[str]:
return hasattr(value, "upper")An object can define an upper method without being a string. This implementation would produce false narrowing. The predicate must accept values that belong to the declared type and reject values outside it according to the intended contract.
Version compatibility
TypeIs is available in modern Python versions. Libraries supporting older interpreters can import it from typing_extensions.
try:
from typing import TypeIs
except ImportError:
from typing_extensions import TypeIsDeclare the minimum dependency and run the checker against every supported version.
TypeIs versus cast
cast() changes only the checker's view and performs no runtime check. TypeIs depends on an actual predicate, returns a Boolean, and can narrow both branches. Prefer real validation at data boundaries. Use a cast only when another mechanism already guarantees the invariant.
Design guidelines
- Give the predicate a name that clearly identifies the subtype.
- Keep the function pure and free of side effects.
- Make the implementation exactly match the annotation.
- Prefer a local
isinstance()when reuse is unnecessary. - Test positive, negative, and unexpected subclass cases.
- Verify behavior with mypy, pyright, or the project's checker.
Complete queue-message example
from dataclasses import dataclass
from typing import TypeIs
@dataclass
class Message:
id: str
@dataclass
class Command(Message):
name: str
arguments: dict[str, object]
@dataclass
class Event(Message):
topic: str
QueueMessage = Command | Event
def is_command(message: QueueMessage) -> TypeIs[Command]:
return isinstance(message, Command)
def dispatch(message: QueueMessage) -> None:
if is_command(message):
execute(message.name, message.arguments)
else:
publish(message.topic)The predicate abstracts the classification rule and narrows both branches. The dispatcher needs no casts or comments explaining the remaining type.
When TypeIs is not appropriate
Do not use TypeIs to convert data, validate the internal contents of invariant containers, or claim a type that is not a subtype of the input. Consider TypeGuard, parsing functions that return a new object, schema libraries, or explicit validation with exceptions.
Conclusion
typing.TypeIs is the right tool for reusable predicates that recognize a genuine subtype. It narrows the true branch by intersection and removes the subtype from the false branch, producing precise control flow.
The official Python TypeIs documentation defines the rules. Compare it with the guide to Python TypeGuard to choose between strict bidirectional narrowing and more flexible validation.







