Static type checkers like Mypy follow your code flow and statically try to figure out the types of the variables without you having to explicitly annotate inline expressions. For example:
# src.py
from __future__ import annotations
def check(x: int | float) -> str:
if not isinstance(x, int):
reveal_type(x)
# Type is now 'float'.
else:
reveal_type(x)
# Type is now 'int'.
return str(x)
The reveal_type
function is provided by Mypy and you don’t need to import this. But
remember to remove the function before executing the snippet. Otherwise, Python will raise a
runtime error as the function is only understood by Mypy. If you run Mypy against this
snippet, it’ll print the following lines:
src.py:6: note: Revealed type is "builtins.float"
src.py:10: note: Revealed type is "builtins.int"
Here, I didn’t have to explicitly tell the type checker how the conditionals narrow the types.
Static type checkers commonly employ a technique called ’type narrowing’ to determine a more precise type of an expression within a program’s code flow. When type narrowing is applied within a block of code based on a conditional code flow statement (such as if and while statements), the conditional expression is sometimes referred to as a ’type guard’. — PEP-647
So, in the above snippet, Mypy performed type narrowing to determine the more precise
type of the variable x
; and the if ... else
conditionals, in this case, is known as
type guards.
However, when the type checker encounters a complex expression, often time, it can’t figure out the types statically. Mypy will complain when it faces one of these issues:
from __future__ import annotations
# In <Python3.9, import this from the 'typing' module.
from collections.abc import Sequence
def check_sequence_str(container: Sequence[object]) -> bool:
"""Check all objects in the container is of type str."""
return all(isinstance(elem, str) for elem in container)
def concat(
container: Sequence[object],
sep: str = "-",
) -> str | None:
"""Concat a sequence of string with the 'sep'."""
if check_sequence_str(container):
return f"{sep}".join(container)
if __name__ == "__main__":
# Mypy complains here, as it can't figure out the
# container type.
concat(["hello", "world"])
Here, the check_sequence_str
checks whether the input argument is a sequence of strings in
runtime. Then in the concat
function, I used it to check whether the input conforms to the
expected type requirement; if it does, the function performs string concatenation on the
input and returns the value. Otherwise, it returns None
. If you run mypy against this,
it’ll complain:
src.py:22: error: Argument 1 to "join" of "str" has incompatible type
"Sequence[object]";
expected "Iterable[str]"
return f"{sep}".join(container)
^
Found 1 error in 1 file (checked 1 source file)
The type checker can’t figure out that the container type is list[str]
.
Functions like check_sequence_str
that—checks the type of an input object and returns a
boolean—are called type guard functions. PEP-647 proposed a TypeGuard
class to help
the type checkers to narrow down types from more complex expressions. Python 3.10 added the
TypeGuard
class to the typing
module. You can use it like this:
# src.py
...
# In <Python3.10, import this from 'typing_extensions' module.
from typing import TypeGuard
def check_sequence_str(
container: Sequence[object],
) -> TypeGuard[Sequence[str]]:
"""Check all objects in the container is of type str."""
return all(isinstance(elem, str) for elem in container)
...
Notice that the return type now has the expected type defined inside the TypeGuard
generic. Now, Mypy will be satisfied if you run it against the modified snippet.
Properties of type guard functions
You’ve already seen how check_sequence_str
narrows down the type of an object in runtime.
Functions like this can be loosely called user-defined type guard functions. However, to be
considered a proper type guard function by the type checker, the callable needs to pass
through a few more checks.
When TypeGuard is used to annotate the return type of a function or method that accepts at least one parameter, that function or method is treated by type checkers as a user-defined type guard. The type argument provided for TypeGuard indicates the type that has been validated by the function. — PEP-647
Usually, a type guard function only takes a single parameter and returns a boolean value based on the conformity of the type of the incoming object with the expected type. The expected type needs to be wrapped in
TypeGuard
and added as the return type annotation.Type checkers will only check if the first positional argument conforms to the expected return type annotation. It’ll ignore other parameters if there is more than one.
If you define a type guard callable in a class, in that case, the type checker will ignore
self/cls
argument and check the second positional parameter for type conformity. Additional parameters won’t be checked.The input type is usually wider than the output type. In our example case, the input type
Sequence[object]
is less specific than that of the return typeSequence[str]
. However, this is mostly a convention and not enforced by any means.
The return type of a user-defined type guard function will normally refer to a type that is strictly “narrower” than the type of the first argument (that is, it’s a more specific type that can be assigned to the more general type). However, it is not required that the return type be strictly narrower. — PEP-647
Generic type guard functions
User-defined type guards can be generic functions, as shown in this example:
from __future__ import annotations
# In <Python3.9, import these from the 'typing' module.
from collections.abc import Generator, Sequence
# In <Python3.10, import TypeGuard from 'typing_extensions'.
from typing import TypeGuard, TypeVar
T = TypeVar("T")
def list_of_t(
container: Sequence[T],
types: tuple = (int, str), # Allowed types in the container.
) -> TypeGuard[list[T]]:
return all(isinstance(elem, types) for elem in container)
def process(container: Sequence[T]) -> Generator[T, None, None]:
if list_of_t(container):
for elem in container:
yield elem
if __name__ == "__main__":
container = ["jupiter", "mars"]
for elem in process(container):
print(elem)
Here, type guard function list_of_t
is a generic function since it accepts a generic
container Sequence[T]
. The first parameter is the input type that the type checker will
focus on, and the second parameter denotes the default types that are allowed inside the
output list. Running the snippet will print jupiter
and mars
in the console and mypy
will also be happy with the types.
Recent posts
- Explicit method overriding with @typing.override
- Quicker startup with module-level __getattr__
- Docker mount revisited
- Topological sort
- Writing a circuit breaker in Go
- Discovering direnv
- Notes on building event-driven systems
- Bash namerefs for dynamic variable referencing
- Behind the blog
- Shell redirection syntax soup