Sitemap
Stephen Ayeni

Stephen Ayeni

Luke 16:10 | Product Management

Python Type Annotation

5 min readFeb 13, 2024

--

Preamble: I am currently undergoing my Masters of Data Science and I am taking two preliminary courses in Python and R. I will be documenting journey in a series of journals that I will post on Medium

Press enter or click to view image in full size
Image Source: Dev.to

In Python only an object can have a type. Variables do not have a type. Variables take on the type from the object they refer too. So this makes python variables tricky to work with. We can’t really use a variable unless we know what type it has currently (which is based on the object it refers too). We need to know this because how would we know what we can do with the variable if we do not know the type

Thus, it is good practise to document the type.

To do this, we first need to know how to name them

Primitive Types

These are some common primitive types that are in-built to Python

Type name/ Sample values

  1. int: 0, 2, 145, -3
  2. float: 4.83, 9.23, 8.66
  3. str: “Welcome”, ‘’
  4. bool: True, False
  5. None: None

None is a special as it is both the value and the name of the type

Compound Types

For compound types like list, dictionary and tuple — they usually go by “list”, “dict” and “tuple”. However, for compound types — we need to add an additional detail which is often the parameter.

Type:

  1. List: A list whose elements are of type (T) — e.g. [1, 2, 3] had type list[int]
  2. Dictionary: A dictionary who has type T1 and who value are of type T2 — [‘a’:1, ‘b’:1, ‘c’:3] had a type of dict[str:int]
  3. Tuple: A tuple who has a first element of T1 and second element of T2 etc. — [‘hello’, 5, True] has type tuple[str, int, bool]

We can also nest these type expression in each other.

E.g. Nested list of [[1, 2, 3], [-2]] has type list[list[int]].

Nested simply mean putting one type (in this case, list) inside another type

Annotating Functions:

We know how to name the types of objects/variables. Now, we learn how to annotate a type of function

Assume we have this function

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

We can tell that this function takes in two integers and it should return a Boolean from the the docstring

Get Stephen Ayeni’s stories in your inbox

Join Medium for free to get updates from this writer.

We annotate each function parameter with this type by writing a “:” and its type afterwards

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

We will annotate the type of return function by writing an arrow “->” and its type after the parenthesis but before the semi-colon

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

Now we can apply any of the types we listed above (Primitive and Complex Types) when annotating a function

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 of a class attribute, we list each attribute along with its type in the body of the class. By convention, we list this at the top of the class after its docstring before any method

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 Method is quite similar to annotating any function except for two minor changes:

  1. We do not annotate the first parameter (self) — this is assumed to be the class of the method by convention
  2. Sometimes we need to refer to the class itself, because it is the type of some other parameter or because it is the type of the return function. By Python process, this requires us to include a special import statement at the top of our Python file.
# 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

They are four more advanced types that you will most likely use:

Two uses special syntax “|” and the other are imported from built-in typing module

Two or more possible types:

Sometimes, we might want to state that a value could take on two or more types. We simply do this by applying the “|” and the types before and after the straight line. The straight line is called “union”

Example: “float|int” mean that the value could take on float or integer type

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

A type or None:

One of the most use cases of “|” is to say that the value can be a type or None. So it is written in this formate “<t>| None” where “<t>” represent any type. This is often used when a value can be a type or None depending on the success or failure condition

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:

Something we want to specify that the value can be anything.

Example: if we are writing a function that takes a list (this could be a list of any type of: Int, bool, str, float etc.) and returns its first element. We will “Any” in this use case and it is imported from “typing” module

from typing import Any


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

Important Note: Some developer abuse the “Any” annotations and use it all the time. This is fruitless. The point of annotating is to communicate as much details as possible to the other programmers. So also choose to go as detailed as possible with your annotations with data types and only use “Any” when absolutely necessary

Important Note 2: Many Python checkers assume “None” is included in the data type for “Any”. However some don’t. So for the sake of learning, we will assume that “Any” is strictly for non-”None” types and so when we want to say “Any” and “None”- we will use “Any|None”

Callable:

What happens when the parameter, return value or instance attribute is a function itself?

This is where we use Callable. How?

  1. First get typing to import Callable
  2. This type has two expressions: 1st is a list of type representing the functions argument, 2nd is the return type

Example:

The type Callable[[int, str], bool]

  1. Argument for the function that takes on two expression: Integer and String
  2. Return Function has a type of 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

--

--

Written by Stephen Ayeni

Luke 16:10 | Product Management