Back to Blog
Python

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.

TypeIsType NarrowingtypingPython 3.13TypeGuardStatic Type Checking
Illustration of Python TypeIs narrowing a value to a specific type in a type checker.

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.

FeatureTypeGuardTypeIs
Narrowing in if branchYesYes
Narrowing in else branchNoYes
Use casePositive type checksBoth positive and negative type checks
IntroducedPython 3.10Python 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 TypeIs must be a subtype of the function's argument type. For example, you cannot use TypeIs[str] if the argument is int, 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 True for values that are not of the specified type, the narrowing will be unsound and can lead to runtime errors.
  • TypeIs only 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.

Python TypeIs: Practical Usage and Code Examples | RYUSLOG DEV