Python TypeIs for Precise Type Narrowing
TypeIs gives Python predicate functions a way to narrow types in both branches of a condition. See practical examples and how it differs from TypeGuard.
TypeIs is a typing construct introduced in Python 3.13 as part of PEP 742. It lets a predicate function tell the type checker what happens in both branches of a conditional: when the predicate returns True, the checked value narrows to the annotated type; when it returns False, the type checker excludes that type.
The Problem with Traditional Type Narrowing
Consider a custom predicate function that returns a boolean. Without a type guard, the type checker cannot narrow the argument's type based on the return value. For example:
def is_str(value: object) -> bool: return isinstance(value, str) def process(value: object) -> None: if is_str(value): # value is still object here, not str print(value.upper()) # Type checker error
Even though is_str returns True only for strings, the type checker does not use that information to narrow value inside the if block. This limitation forces developers to use isinstance directly or add explicit casts, which reduce safety and readability.
Introducing TypeIs
TypeIs is a construct in the typing module introduced in Python 3.13 via PEP 742. It is used as a return annotation for a function that takes an argument and returns a boolean. The annotation specifies the type that the argument is narrowed to when the function returns True. The key difference from a plain bool return is that the type checker also understands the negative branch: when the function returns False, the argument is narrowed to exclude the specified type.
Here is the basic syntax:
from typing import TypeIs def is_str(value: object) -> TypeIs[str]: return isinstance(value, str)
Now, when is_str is used in a condition, the type checker narrows value to str in the if branch and excludes str from the possible types in the else branch.
How TypeIs Works
The TypeIs annotation declares a contract between the function and the static type checker. If the function returns True, the argument is guaranteed to be of the specified type. If it returns False, the argument is guaranteed not to be of that type. This bidirectional narrowing is what distinguishes TypeIs from a simple boolean return.
The function must actually enforce this contract at runtime. The type checker trusts the annotation, so if the function does not behave as declared, the narrowing will be incorrect. Therefore, TypeIs should be used only for functions that perform a genuine type check, typically by delegating to isinstance or other runtime type validation.
TypeIs vs TypeGuard
Before TypeIs, Python had TypeGuard, introduced in Python 3.10. TypeGuard also narrows the type in the if branch, but it does not provide any information about the else branch. This means that with TypeGuard, the type checker cannot assume that the argument is not of the guarded type when the function returns False.
| Feature | TypeGuard | TypeIs |
|---|---|---|
Narrowing in if branch | Yes | Yes |
Narrowing in else branch | No | Yes |
| Use case | Positive type checks | Both positive and negative type checks |
| Introduced | Python 3.10 | Python 3.13 |
Consider the same predicate written with TypeGuard:
from typing import TypeGuard def is_str_guard(value: object) -> TypeGuard[str]: return isinstance(value, str) def process_guard(value: object) -> None: if is_str_guard(value): print(value.upper()) # value is str else: # value is still object, not narrowed print(value) # no error, but no narrowing
With TypeIs, the else branch is narrowed to exclude the guarded type. For a union such as int | str, that means the else branch can use the remaining member directly. This is especially useful when dealing with unions or complex type hierarchies.
Practical Examples with TypeIs
Narrowing to a Union Member
Suppose you have a union type and want to narrow to one of its members:
def is_int_or_str(value: int | str) -> TypeIs[str]: return isinstance(value, str) def handle(value: int | str) -> None: if is_int_or_str(value): print(value.upper()) # value is str else: print(value + 1) # value is int
In the else branch, the type checker knows that value is int because it cannot be str. This is more precise than using TypeGuard, where the else branch would still be int | str.
Using with Custom Checks
TypeIs works with any runtime check, not just isinstance. For example, you might check for a specific attribute or a structural pattern:
class HasName: name: str def has_name(value: object) -> TypeIs[HasName]: return hasattr(value, "name") def greet(value: object) -> None: if has_name(value): print(f"Hello, {value.name}") else: # value does not have name attribute print("No name available")
Here, TypeIs[HasName] tells the type checker that inside the if branch, value can be treated as HasName. The else branch is narrowed to exclude HasName, which is useful for fallback logic.
Combining with isinstance
TypeIs can also be used to wrap isinstance checks for multiple types, but the annotation must be a single type. For checking multiple types, you would need separate functions or a union type:
def is_number(value: object) -> TypeIs[int | float]: return isinstance(value, (int, float)) def process(value: object) -> None: if is_number(value): print(value * 2) # value is int or float else: print("Not a number")
Note that the type in TypeIs can be a union, and the narrowing works accordingly.
Limitations and Constraints
TypeIs is a powerful tool, but it has specific constraints that you must respect:
- The annotated function must return a
bool. If it returns anything else, the type checker will reject the annotation. - The type specified in
TypeIsmust be a subtype of the function's argument type. For example, you cannot useTypeIs[str]if the argument isint, because a string is not a subtype of int. - The function must actually perform a type check that matches the annotation. If the function returns
Truefor values that are not of the specified type, the narrowing will be unsound and can lead to runtime errors. TypeIsonly affects static type checking. It has no runtime behavior and does not enforce types at runtime. It is purely a hint for static type checkers.
These constraints mean that TypeIs is not a replacement for runtime validation libraries like Pydantic. It is designed to improve the precision of static type checking in your codebase.
Runtime Behavior and Compatibility
TypeIs is a typing-only construct. The type checker uses the annotation to narrow types, but the runtime behavior of the function is whatever its body does. There is no runtime type enforcement from TypeIs itself.
Because TypeIs was introduced in Python 3.13, it is not available in earlier versions. However, the typing_extensions package provides a backport for older Python versions. You can use from typing_extensions import TypeIs to use it when supporting multiple Python versions. Check the package's supported Python versions and your type checker's documentation before relying on it.
Type checkers must also support TypeIs to take advantage of it. Recent versions of mypy and pyright support PEP 742, but verify that your tooling is up to date. If your type checker does not recognize TypeIs, it will treat it as a plain bool, and you will not get the narrowing benefits.
When you use TypeIs, ensure that the function's logic is straightforward and testable. Since the type checker trusts the annotation, any mistake in the runtime check will produce incorrect narrowing. Write unit tests for your type guard functions to confirm they return the expected results for both positive and negative cases.