Python Type Hints - How to Narrow Types with isinstance(), assert, and Literal2021-05-17
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
assert, and comparisons of
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.
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
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]'
if, Mypy can narrow the type of
And inside the
else, Mypy can infer that
name must be
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)
$ 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
name must be a
But under the
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)).
assert statement raises an
AssertionError if the given expression is
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
It is not recommended for most applications.
For an example, let’s call
This returns the current execution frame, an instance of
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
If we are writing our code to only run on CPython, we can use an
assert to guard against the return value being
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'
assert, Mypy reveals
frame has having the type
FrameType | None, in its long-form spelling.
assert, Mypy has narrowed the type down to just
Mypy also supports
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)
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.
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
$ 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
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
from __future__ import annotations name: str | None isinst = isinstance if isinst(name, str): reveal_type(name)
Thanks to the check, under the
name will always be a
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
str | None under the
If you’re unsure if a certain expression narrows types, debug with
Custom type narrowing logic is also possible, thanks to PEP 647 which introduced
This is available on Python 3.10+ or in
Guido van Rossum added support to Mypy but this hasn’t yet been released.
TypeGuard seems like a good subject for a future post!
🎉 My book Speed Up Your Django Tests is now up to date for Django 3.2. 🎉
Buy now on Gumroad
One summary email a week, no spam, I pinky promise.
- Python Type Hints - How to Debug Types With reveal_type()
- Python Type Hints - How to Specify a Class Rather Than an Instance Thereof
- Python Type Hints - Use object instead of Any
Tags: mypy, python
© 2021 All rights reserved.