Typing Introduction#

An Introduction to Type Annotations#

Since Python 3.5, the Python standard library has included the typing module for annotating function and method arguments and return values with type information. This information can be used by static type checkers such as mypy to check that the code is type safe. It can also be used by IDEs to provide type hints and even autocompletion.

A simple example of type annotations is the following function that takes two arguments, x and y, and returns their sum.

def add(x: float, y: float) -> float:
    return x + y

In this example, the x and y arguments are annotated as float and the return value is annotated as float. This tells the static type checker that the function expects two float arguments and returns a float value. The static type checker can then check that the function is called with the correct types of arguments and that the return value is used correctly. It is important to note that the type annotations are not enforced at runtime.

Typing in Python also supports generic types, where the types of the arguments and return value is contextual, and can be different for different calls to the function. For example, instead of taking two float arguments, the add function could take any two arguments of the same type and return a value of that type.

from typing import TypeVar  # How we express generic type variable

T = TypeVar("T")  # Declare a generic type variable


def add(x: T, y: T) -> T:  # two arguments of the same type -> same return type
    return x + y
>>> add(1, 2)  # int + int -> int
3

>>> add(1.0, 2.0)  # float + float -> float
3.0

>>> add("foo", "bar")  # str + str -> str
'foobar'

The first two examples, adding integers and floats, probably made sense to you. The third example, adding strings, might have been a bit more surprising. But the add function is generic, so it can be used with any type that supports addition. If we want to make our add function only work for numerical types such as int and float, we have to constrain the generic type variable. The details of how to do this are beyond the scope of this introduction, but the following example shows how to constrain the generic type variable T to numerical types.

from typing import TypeVar
from numbers import Number

T = TypeVar("T", bound=Number)


def add(x: T, y: T) -> T:
    return x + y

Now the add function can only be called with arguments that are instances or subclasses of Number, like int and float. But what about a ndarray? It is not a subclass of Number, but it supports addition. To support ndarray as well, we can use the typing.Union type to allow either Number or ndarray as arguments.

from typing import TypeVar, Union
from numbers import Number
import numpy as np

T = TypeVar("T", bound=Union[Number, np.ndarray])


def add(x: T, y: T) -> T:
    return x + y
>>> add(1, 2)
3

>>> add(1.0, 2.0)
3.0

>>> add(np.array([1, 2]), np.array([3, 4]))
array([4, 6])

Now numpy is great, but what about a Dask array or a Jax array? They are not a subclass of Number or ndarray, but they support addition. We could just add them to the Union type, but that would be tedious and wouldn’t help with Cupy or Pytorch, etc. Instead of listing each types that we want to support, we can instead use the tools in typing to build a generic type that describes all of the types that we want to support. This is called duck-typing (or structural subtyping) and is implemented in Python using typing.Protocol.

An Introduction to Protocols#

Since PEP 544 was implemented in Python 3.8, Python can now separate the description of an API from its implementation. This is done using the typing.Protocol class. Protocols are essentially abstract base classes that don’t require inheritance. Instead, they are used to describe the interface of an object. Any object that implements the interface is considered a subclass of the Protocol and the class’ instances are likewise instances of the Protocol. This is called “structural subtyping” or “duck typing”.

As an example, consider the following Protocol that describes the interface of an object that has a name and a value.

from typing import Protocol


class NamedValue(Protocol):
    """Interface for an object that has a name and a value."""

    value: float
    name: str

This Protocol can be used to annotate a function that takes a NamedValue duck-type as an argument.

def print_value(x: NamedValue) -> None:
    print(f"{x.name}: {x.value}")

Any class that has a value attribute of type float and a name attribute of type str is considered a subclass of NamedValue and can be used as an argument to print_value.

class NamedValueClass1:
    def __init__(self, name: str, value: float):
        self.name = name
        self.value = value


v = NamedValueClass1("foo", 1.0)

Or

from typing import NamedTuple


class NamedValueClass2(NamedTuple):
    name: str
    value: float


v = NamedValueClass2("foo", 1.0)

Note again that neither NamedValueClass1 nor NamedValueClass2 inherit from NamedValue. This is the power of structural subtyping with typing.Protocol.

Returning to our add function, we can now use a Protocol to describe any of the Array libraries.

from typing import Any


class Array(Protocol):
    @property
    def shape(self) -> tuple[int, ...]:
        ...

    @property
    def dtype(self) -> Any:
        ...

    ...

    def __add__(self, other: "Array") -> "Array":
        ...

Applying this to our add function, we get the following.

from typing import TypeVar, Union, Protocol
from numbers import Number
import numpy as np

T = TypeVar("T", bound=Union[Number, Array])


def add(x: T, y: T) -> T:
    return x + y
>>> add(1.0, 2.0)
3.0

>>> add(np.array([1, 2]), np.array([3, 4]))
array([4, 6])

The add function now works with any numerical type or any array type that looks like Array, like numpy.ndarray, dask.array.Array, jax.Array, etc.

In this Project#

This API is built on the Array interface of the Array API project. The Array interface is not (yet) a Protocol, so this project privately defines a Protocol for Array. We note that our version is a subset of the Array interface defined by the Array API project. This is because the Array API project is new and standard numpy.ndarray is not yet fully compatible, though numpy plans full support.

In this project you will see the Array Protocol used throughout the API. Also, there is a generic type variable InputT that is used to describe the type of the input to a function. This is a TypeVar. Due to the cuurent limitations of Python, this is an unconstrained TypeVar but it is intended to be constrained to Array + other, e.g. float. In future, InputT will be constrained.