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

Narrow typographer’s tweezers.

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 asserts 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!

Update (2021-06-09): It’s the future and I’ve now written ✨ that post ✨.

Fin

May your types be as narrow as necessary,

—Adam


Learn how to make your tests run quickly in my book Speed Up Your Django Tests.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: ,