Python Type Hints - How to Narrow Types with isinstance(), assert, and Literal

Type narrowing is the ability for a type checker to infer that inside some branch, a variable has a more specific (narrower) type than its definition. This allows us to perform type-specific operations without any explicit casting.
Type checkers, such as Mypy, support type narrowing based on certain expressions, including isinstance()
, assert
, and comparisons of Literal
types. In this post we’ll explore those type narrowing expressions, using reveal_type()
to view the inferred types. We’ll use Mypy, but other type checkers should support narrowing with a similar set of expressions.
Note that this list of expressions is non-exhaustive. Type checkers may support more Python syntax, now or in the future, for example Python 3.10’s structural pattern matching.
Narrowing with isinstance()
Inside an if
using isinstance()
, Mypy can infer that the checked variable is of the given type. Similarly, from a not isinstance()
check, Mypy can infer the variable is not the given type. Mypy will also propagate the opposite inference to any elif
/ else
branches.
For example, take this code:
from __future__ import annotations
name: str | None
if isinstance(name, str):
reveal_type(name)
else:
reveal_type(name)
reveal_type(name)
Running Mypy logs the revealed types:
$ mypy example.py
example.py:6: note: Revealed type is 'builtins.str'
example.py:8: note: Revealed type is 'None'
example.py:9: note: Revealed type is 'Union[builtins.str, None]'
Inside the if
, Mypy can narrow the type of name
to str
. And inside the else
, Mypy can infer that name
must be None
. At the end, name
again has the type str | None
, with the long-form spelling.
Mypy also supports type narrowing under if
statements that combine isinstance()
with other expressions. For example, with a random switch:
from __future__ import annotations
import random
name: str | None
if isinstance(name, str) and random.choice([True, False]):
reveal_type(name)
else:
reveal_type(name)
Running Mypy:
$ mypy example.py
example.py:8: note: Revealed type is 'builtins.str'
example.py:10: note: Revealed type is 'Union[builtins.str, None]'
Mypy can tell that under the if
branch, name
must be a str
. But under the else
, name
cannot be narrowed, so the revealed type is str | None
, in its long-form spelling.
Another thing Mypy supports is narrowing when checking against a tuple of types, for example isinstance(name, (int, str))
.
Narrowing with assert
Python’s assert
statement raises an AssertionError
if the given expression is False
. Mypy supports narrowing through assert
statements based on the type or value of variables.
Note that Python’s optimized mode, activated with python -O
, removes assert
statements. It is not recommended for most applications.
For an example, let’s call inspect.currentframe()
. This returns the current execution frame, an instance of FrameType
. But in non-CPython implementations, currentframe()
can return None
, as its documentation notes:
CPython implementation detail: This function relies on Python stack frame support in the interpreter, which isn’t guaranteed to exist in all implementations of Python. If running in an implementation without Python stack frame support this function returns None
.
If we are writing our code to only run on CPython, we can use an assert
to guard against the return value being None
. For example:
import inspect
frame = inspect.currentframe()
reveal_type(frame)
assert frame is not None
reveal_type(frame)
Running this with Mypy:
$ mypy example.py
example.py:4: note: Revealed type is 'Union[types.FrameType, None]'
example.py:6: note: Revealed type is 'types.FrameType'
Before the assert
, Mypy reveals frame
has having the type FrameType | None
, in its long-form spelling. After the assert
, Mypy has narrowed the type down to just FrameType
.
Mypy also supports assert
s using isinstance()
, so we could also write:
import inspect
from types import FrameType
frame = inspect.currentframe()
reveal_type(frame)
assert isinstance(frame, FrameType)
reveal_type(frame)
Noice.
Narrowing Literal
values
A typing.Literal
type allows only one of a few given literal values, such as strings or numbers. Mypy can evaluate basic comparisons against Literal
variables to narrow types.
For example:
from typing import Literal
Mode = Literal["read", "write"]
mode: Mode
if mode == "read":
reveal_type(mode)
else:
reveal_type(mode)
reveal_type(mode)
We define the Mode
type to take either of the strings "read"
and "write"
. Running Mypy:
$ mypy example.py
example.py:8: note: Revealed type is 'Literal['read']'
example.py:10: note: Revealed type is 'Literal['write']'
example.py:11: note: Revealed type is 'Union[Literal['read'], Literal['write']]'
Under each branch, Mypy infers a narrowed Literal
type for the value that mode
can hold at that point. At the end, it uses its long-form spelling of Mode
using a Union
.
A warning on expressions
Note that type checkers do not execute your code, so they cannot evaluate arbitrary expressions. They can only evaluate certain safe expressions, using abstract types rather than specific values.
If you do not use a supported form for your expressions, the type checker cannot narrow types. For example, we could write our first example with an alias to isinstance
:
from __future__ import annotations
name: str | None
isinst = isinstance
if isinst(name, str):
reveal_type(name)
Thanks to the check, under the if
, name
will always be a str
. But if we run Mypy:
$ mypy example.py
example.py:8: note: Revealed type is 'Union[builtins.str, None]'
Mypy cannot detect that isinst
will always be isinstance
, so it sees name
as str | None
under the if
.
If you’re unsure if a certain expression narrows types, debug with reveal_type()
.
Custom type narrowing logic is also possible, thanks to PEP 647 which introduced typing.TypeGuard
. This is available on Python 3.10+ or in typing-extensions
. Guido van Rossum added support to Mypy but this hasn’t yet been released. TypeGuard
seems like a good subject for a future post!
Learn how to make your tests run quickly in my book Speed Up Your Django Tests.
One summary email a week, no spam, I pinky promise.
Related posts: