1.4 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 relatively new 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 its type.

Compound types

For compound types like lists, dictionaries, and tuples, we can also just use their type names: list, dict, and tuple. 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.

If we import the typing module, it provides us with a way of expressing these more detailed types. The table below shows three complex types from the typing module; the capitalized words in square brackets could be substituted with any type. Note that we use square brackets, not round ones, for these types.

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. Just remember to import the typing module!

from typing import List, Tuple


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.

from typing import Dict, Tuple


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. All four of these types are imported from the typing module.

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:

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.

Union

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 Union type. For example, the type Union[int, float] represents the type of a value that could be either an int or a float.

from typing import Union


def cube_root(x: Union[int, float]) -> float:
    return x ** (1/3)

Optional

One of the most common uses of a “union type” 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. Rather than write Union[int, None], there’s a slightly shorter version from the typing module called Optional. The type expression Optional[T] is equivalent to Union[T, None] for all type expressions T. Here is an example:

from typing import Optional


def find_pos(numbers: List[int]) -> Optional[int]:
    """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

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