1.5 Python Type Annotations#

In many programming languages, we cannot use a variable until we have declared its type, which determines the values that can be assigned to it; furthermore, a variable’s type can never change. Python takes a very different approach: only objects have a type, not the variables that refer to those objects; and in fact, a variable can refer to any type of object. Nonetheless, we can’t use a Python variable unless we know what type of object it refers to at the moment—how would we know what we can do with it?

Since we need to be aware of the types we are using at any point in our code, it is good practise to document this. In this course, we will document the types of all functions and class instance attributes. We’ll use Python’s type annotation syntax to do so.

Before we can begin documenting types, we need to learn how to name them.

Primitive types#

For primitive types, we can just use their type names. The table below gives the names of the common primitive types that are built into Python. There are other built-in types that are omitted because we tend not to use them in this course.

Type name

Sample values

int

0, 148, -3

float

4.53, 2.0, -3.49

str

'hello world', ''

bool

True, False

None

None

Note that None is a bit special, as we refer to it as both a value and the name of its type.

Compound types#

For compound types like lists, dictionaries, and tuples, we can also just use their type names: list, dict, and tuple.[1] But often we need to be more specific. For example, often we want to say that a function takes in not just any list, but only a list of integers; we might also want to say that this function returns not just any tuple, but a tuple containing one string and one boolean value.

We can use square brackets to express the “contained” types for each of these compound types. The table below shows examples of this syntax for each type; the capitalized words in square brackets could be substituted with any type.

Type

Description

Example

list[T]

a list whose elements are all of type T

[1, 2, 3] has type list[int]

dict[T1, T2]

a dictionary whose keys are of type T1 and whose values are of type T2

{'a': 1, 'b': 2, 'c': 3} has type dict[str, int]

tuple[T1, T2, ...]

a tuple whose first element has type T1, second element has type T2, etc.

('hello', True, 3.4) has type tuple[str, bool, float]

We can nest these type expressions within each other; for example, the nested list [[1, 2, 3], [-2]] has type list[list[int]].

Sometimes we want to be flexible and say that a value must be a list, but we don’t care what’s in the list (e.g. it could be a list of strings, a list of integers, a list of strings mixed with integers, etc.). In such cases, we can simply use the built-in types list, dict, and tuple for these types.

Annotating functions#

Now that we know how to name the various types, let’s see how we can use this to annotate the type of a function.

Suppose we have the following function:

def can_divide(num, divisor):
    """Return whether num is evenly divisible by divisor."""
    return num % divisor == 0

This function takes in two integers and returns a boolean. We annotate the type of a function parameter by writing a colon and type after it:

def can_divide(num: int, divisor: int):

We annotate the return type of the function by writing an arrow and type after the close parenthesis, and before the final colon:

def can_divide(num: int, divisor: int) -> bool:

We can use any of the type expressions discussed above in these function type annotations, including types of lists and dictionaries.

def split_numbers(numbers: list[int]) -> tuple[list[int], list[int]]:
    """Return a tuple of lists, where the first list contains the numbers
    that are >= 0, and the second list contains the numbers that are < 0.
    """
    pos = []
    neg = []
    for n in numbers:
        if n >= 0:
            pos.append(n)
        else:
            neg.append(n)
    return pos, neg

Annotating instance attributes#

To annotate the instance attributes of a class, we list each attribute along with its type directly in the body of the class. By convention, we usually list these at the very top of the class, after the class docstring and before any methods.

class Inventory:
    """The inventory of a store.

    Keeps track of all of the items available for sale in the store.

    Attributes:
        size: the total number of items available for sale.
        items: a dictionary mapping an id number to a tuple with the
            item's description and number in stock.
    """
    size: int
    items: dict[int, tuple[str, int]]

    ...  # Methods omitted

Annotating methods#

Annotating the methods of a class is the same as annotating any other function, with two notable exceptions:

  1. By convention, we do not annotate the first parameter self. Its type is always understood to be the class that this method belongs to.

  2. Sometimes we need to refer to the class itself, because it is the type of some other parameter or the return type of a method. Because of a quirk of Python, we can only do so by including a special import statement at the very top of our Python file.

Here is an example (for brevity, method bodies are omitted):

# This is the special import we need for class type annotations.
from __future__ import annotations


class Inventory:

    # The type of self is omitted.
    def __init__(self) -> None:
        ...
    def add_item(self, item: str, quantity: int) -> None:
        ...
    def get_stock(self, item: str) -> int:
        ...

    def compare(self, other: Inventory) -> bool:
        ...
    def copy(self) -> Inventory:
        ...
    def merge(self, others: list[Inventory]) -> None:
        ...

Four advanced types#

Here are four more advanced types that you will find useful throughout the course. The first two use a special syntax, | in the type annotation. The latter two are types imported from the built-in typing module.

Two or more possible types#

We sometimes want to express in a type annotation that a value could be one of two different types; for example, we might say that a function can take in either an integer or a float. To do so, we use the vertical bar | (“union”) to separate the type names. For example, the type annotation int | float represents the type of a value that could be either an int or a float.

def cube_root(x: int | float) -> float:
    return x ** (1 / 3)

A type or None#

One of the most common uses of the | is to say that a value could be a certain type, or None. For example, we might say that a function returns an integer or None, depending on some success or failure condition. For all type expressions <T>, we can write <T> | None to express this. Here is an example:

def find_pos(numbers: list[int]) -> int | None:
    """Return the first positive number in the given list.

    Return None if no numbers are positive.
    """
    for n in numbers:
        if n > 0:
            return n

Any#

Sometimes we want to specify the that the type of a value could be anything (e.g., if we’re writing a function that takes a list of any type and returns its first element). We annotate such types using Any, which is imported from the typing module.

from typing import Any


# This function could return a value of any type
def get_first(items: list) -> Any:
    return items[0]

Warning: beginners often get lazy with their type annotations, and tend to write Any even when a more specific type annotation is appropriate. While this will cause code analysis tools (like PyCharm or python_ta) to be satisfied and not report errors, overuse of Any completely defeats the purpose of type annotations! Remember that we use type annotations as a form of communication, to tell other programmers how to use our function or class. With this goal in mind, we should always prefer giving specific type annotations to convey the most information possible, and only use Any when absolutely necessary.

Note: Many Python type checkers interpret Any as including the possibility of a None value. However, for educational purposes in this course, we will use the type annotation Any | None to explicitly indicate when a value can be of any type, including None. When we write Any by itself, we’ll mean “any non-None type”.

Callable#

Finally, we sometimes need to express that the type of a parameter, return value, or instance attribute is itself a function. To do so, we use the Callable type from the typing module. This type takes two expressions in square brackets: the first is a list of types, representing the types of the function’s arguments; the second is its return type. For example, the type Callable[[int, str], bool] is a type expression for a function that takes two arguments, an integer and a string, and returns a boolean. Below, the type annotation for compare_nums declares that it can take any function that takes two integers and returns a boolean:

from typing import Callable


def compare_nums(num1: int, num2: int,
                 comp: Callable[[int, int], bool]) -> int:
    if comp(num1, num2):
        return num1
    else:
        return num2


def is_twice_as_big(num1: int, num2: int) -> bool:
    return num1 >= 2 * num2


>>> compare_nums(10, 3, is_twice_as_big)
10
>>> compare_nums(10, 6, is_twice_as_big)
6