Python typeis: Using TypeIs for Precise Type Narrowing
python typeis: Learn how Python's TypeIs improves type narrowing with precise control over type checking in both branches of a condition.
python typeis requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
When you write a function that checks whether a value is of a specific type, the type checker often cannot infer the type in the negative branch. Python 3.13's TypeIs solves this by allowing a function to indicate that a successful check narrows the type, and a failed check eliminates that type. This guide covers the syntax, behavior, and practical usage of TypeIs.
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 new 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 to object minus str 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 str, which can catch errors when you accidentally call string methods on a value that is not a string. 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: n 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 tools like mypy, pyright, and PyCharm.
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. When the Python interpreter runs a function annotated with TypeIs, the annotation is not evaluated at runtime. The function behaves exactly as if it had a plain bool return type. This means there is no performance overhead or runtime type enforcement.
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 in Python 3.8 and later. This is useful for projects that need to support multiple Python versions.
Type checkers must also support TypeIs to take advantage of it. Most modern type checkers, including mypy and pyright, have added support for PEP 742. If your type checker does not recognize TypeIs, it will treat it as a plain bool, and you will not get the the narrowing benefits. Always verify that your tooling is up to to date when adopting this feature.
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.