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.
Equality
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:
# 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
else:
# 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
return
reveal_type(value) #> str
Nominal
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
else:
reveal_type(value) #> A
Structural
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
Casts
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))