Type Narrowing

Type narrowing is the process of distinguishing more specific subtypes from a broader type. The type checker will narrow the type of an object based on type guards and control flow expressions. This allows us to safely handle the possible states of our application, or just to convince the type checker that what we intend to do with an object is ok.

Type Guards

Expressions which can narrow types are called "type guards" since the type narrowing applies to the code block "guarded" by the expression.

Below are some examples of type guards. A comprehensive list of type guards exists in the Pyright documentation.


Testing equality with None, Enum, bool, or a literal will narrow the type in the guarded block:

def example(value: str | None):
    if not value:
        reveal_type(value)  #> None
        # Else block can also be narrowed
        reveal_type(value)  #> str

    # Narrowing only applies to guarded block
    reveal_type(value)  #> str | None

def example2(value: int | None):
    if value:
        reveal_type(value)  #> int
        # 0 is falsy, so this clause cannot be narrowed!
        reveal_type(value)  #> int | None

def example3(value: str | None):
    # Guard clauses which return or raise will narrow the remainder of the block
    if not value:
        reveal_type(value)  #> None
    reveal_type(value)  #> str


We can check types more directly with isinstance, which allows us to do nominal subtyping at runtime: it returns whether an object is an instance of a class or a subclass of that class:

def example(value: int | str):
    if isinstance(value, int):
        reveal_type(value)  #> int

class A: pass
class B: pass
class C(B): pass

def example2(value: A | B | C):
    if isinstance(value, B):
        reveal_type(value)  #> B | C
        reveal_type(value)  #> A


We can also narrow structurally by looking for distinguishing members with a Literal type. Literal types are the only ones which can be used for structural narrowing. We cannot narrow to Plant by checking if the value has the attribute leaves, even though it is unique to the type Plant:

from typing import Literal

class Plant:
    kingdom: Literal["plantae"]
    leaves: int

class Animal:
    kingdom: Literal["animalia"]
    legs: int

def correct(value: Plant | Animal):
    if value.kingdom == "plantae":
        reveal_type(value)  #> Plant

def incorrect(value: Plant | Animal):
    if hasattr(value, 'leaves'):
        reveal_type(value)  #> Plant | Animal

The same works for TypedDicts:

from typing import Literal, TypedDict

class Plant(TypedDict):
    kingdom: Literal["plantae"]

class Animal(TypedDict):
    kingdom: Literal["animalia"]

def example(value: Plant | Animal):
    if value["kingdom"] == "plantae":
        reveal_type(value)  #> Plant


We can cast a type to coerce the type checker into using a type for a value. Be very careful with this, as it can easily undermine type safety. Casting is best used in the cases where there is just not enough information available to the type checker.

value = cast(list[str], [5])
reveal_type(value)  #> list[str]

If a cast later becomes unnecessary (ex. it was correcting a bug which has been fixed), Pyright will generate an error so it can be removed for greater type safety.

User-Defined Type Guards

It is possible to define a type guard function for cases which can't be narrowed statically.1 A type guard function should return a bool, indicating whether the argument satisfies the type guard.

from typing import TypeGuard

def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in val)

def example(val: list[object]) -> None:
    if is_str_list(val):
        reveal_type(val)  #> list[str]
        print(" ".join(val))


  1. User-Defined Type Guards were specified in PEP-647