Stanley Ulili I'm a freelance web developer and researcher from Malawi. I love learning new things, and writing helps me understand and solidify concepts. I hope by sharing my experience, others can learn something from them.

Understanding type annotation in Python

20 min read 5849

Understanding type annotation in Python

Dynamically typed languages are great for rapid prototyping, but as the codebase grows, the risk of type errors increases. To reduce these errors, Python 3.5 introduced type hints, which can be added to code using the type annotations introduced in Python 3.0.

With type hints, you can annotate variables and functions with types. Python does not enforce the types on runtime; instead, static type-checking tools like mypy, pyright, or IDEs enforce the types and emit warnings when types are used inconsistently.

Using static type checkers has numerous advantages:

  • Detecting type errors
  • Preventing bugs
  • Documenting your code — anyone who wants to use an annotated function will know the type of parameters it accepts, and the return value type at a glance
  • Additionally, IDEs understand your code much better and offer good auto-completion suggestions

Static typing in Python is optional and can be introduced gradually (this is known as gradual typing). With gradual typing, static type-checkers do not give out warnings on code without type hints, nor do they prevent inconsistent types from compiling during runtime.

In this tutorial, you’ll learn how to add type hints to Python and how to use mypy for static type checking. You’ll annotate variables, functions, lists, dictionaries, and tuples. You’ll also learn how to use protocols, function overloading, and annotating constants.

Before you begin

To get the most out of this tutorial, you should have:

  • Python ≥3.10 installed
  • Knowledge of how to write functions, f-strings, and running Python code
  • Knowledge of how to use the command-line

We recommend Python ≥3.10, as those versions have new and better type-hinting features. If you’re using Python ≤3.9, Python provides an alternatives type-hint syntax; we will mention it in the tutorial.

What is mypy?

mypy is a optional static type checker created by Jukka Lehtosalo. It checks for annotated code in Python and emits warnings if annotated types are used inconsistently. mypy also checks the code syntax and issues syntax errors when it encounters invalid syntax.

mypy supports gradual typing, allowing you to add type hints in your code slowly at your own pace.

What is strong dynamic typing?

Python is a strong dynamically typed language. Strong typing means that it has strict typing rules and does not produce unexpected results.

We made a custom demo for .
No really. Click here to check it out.

For example, in Python, you can’t add an integer to a string or it will throw a type error:

>>> 3 + "hello"
TypeError: unsupported operand type(s) for +: 'int' and 'str'

In comparison, a weak-typed language like JavaScript will do an implicit conversion of the types, and running the operation 3 + "hello" will produce a valid output:

> 3 + "hello";
 "3hello"; // output

Dynamic typing in Python means that the interpreter checks the types only when the program is running and you’re free to change the variable type any time:

>>> x = "hello"
>>> type(x)
<class 'str'>   # variable type is string
>>> x  = 3
>>> type(x)
<class 'int'>   # variable type changed to integer

What is static type checking?

In statically-typed languages like C and Java, when you assign a variable type, you can’t change the type during runtime. For example, if you declare a variable to be an integer, you can’t change it to a string later.

int x = 4;
x = "hello";  // this would trigger a type error

In statically-typed languages, a compiler checks the types after code is written. If no issues are found , the program can be run.

Everything is Python objects

In Python, everything is an object. Whether you’re defining a string, integer, float, list, or dictionary — they are all objects.

We can verify this by checking using the isinstance method):

>>> isinstance(str, object)
True
>>> isinstance(int, object)
True

As you can see, both strings (str) and integer (int) have been confirmed to be instances of the object.

Since everything is an object, everything also has attributes and methods. For example, if you define an integer, you can see all of the attributes using dir:

>>> x = 8
>>> dir(x)
[...'__add__', '__mul__', '__str__', '__divmod_', ''real', ...] # edited for brevity

The methods with double leading and trailing underscores show the operations the int type supports. For instance, the __mul__ method does the multiplication operation.

>>> x = 8
>>> x.__mul__(2)
16

As you go through the article, you’ll learn that objects can be grouped into certain type hints because of the methods they have.

For example, objects are categorized as a Sequence type if they have __getitem__, and __len__ methods. Examples of those objects are list, string, tuple (output edited for brevity):

>>> dir([2,3])   # list
[...'__getitem__', '__len__'...]
>>> dir((3,2))    # tuple
[...'__getitem__', '__len__'...]
>>> dir('hello')   # string
[...'__get_item__', '__len__'...]

You don’t have to worry about this if you don’t understand right now — we will cover the Sequence type in the later sections in greater detail. But the takeaway here should be that everything is an object and has methods. These methods are used to group certain objects into certain types.

With that said, we will begin annotating variables in the next section.

Adding type hints to variables

In this section, you’ll learn how to add type hints to variables.

In Python, you can define a variable with a type hint using the following syntax:

variable_name: type = value

Let’s look at the following variable:

name = "rocket”

We assign a string value "rocket" to the name variable.

To annotate the variable, we need to append a colon (:) after the variable name, and declare a type str:

name: str = "rocket"

In Python, you can read the type hints defined on variables using the __annotations__ dictionary:

>>> name: str = "rocket"
>>> __annotations__
{'name': <class 'str'>}

The __annotations__ dictionary will show you the type hints on all global variables.

You’ve now added a str type hint to the variable. As mentioned earlier, the Python interpreter does not enforce types, so defining a variable with a wrong type won’t trigger an error:

>>> name: int = "rocket"
>>>

On the other hand, a static type checker like mypy will flag this as an error:

error: Incompatible types in assignment (expression has type "str", variable has type "int")

Declaring type hints for other data types follows the same syntax. The following are some of the simple types you can use to annotate variables:

  • float: float values, such as 3.10
  • int: integers, such as 3, 7
  • str: strings, such as 'hello'
  • bool: boolean value, which can be True or False
  • bytes: represents byte values, such as b'hello'

When you declare a variable with a simple type like int, or str, mypy can infer the type. So, most of the time, annotating variables with these simple types won’t be necessary.

However, mypy struggles to infer types on variables that store complex structures, such as lists, dictionary, or tuples. And this is where type hints on variables are more important.

In the coming sections, you’ll learn how to add type hints to those structures, but for now, we’ll add type hints to functions.

Adding types hints to functions

We’ll now add type hints to functions. To annotate a function, we will annotate each parameter and the return value:

def function_name(param1: param1_type, param2: param2_type) -> return_type:

Let’s annotate the following function that returns a message:

def announcement(language, version):
    return f"{language} {version} has been released"

announcement("Python", 3.10)

The function accepts a string as the first parameter and a float as the second parameter. It then returns a string.

To annotate the function parameters, we will append a colon(:) after each parameter and follow it with the parameter type:

  • language: str
  • version: float

To annotate return value type, we will add -> before the function definition colon(:). It will look as follows:

def announcement(language: str, version: float) -> str:
    ...

The function now has type hints showing that it receives str and float arguments, and returns str.

When you invoke the function, you will get:

result = announcement("Python", 4.11)
print(result) # Python 4.11 has been released

Although our code has type hints, the Python interpreter won’t give out warnings if we call the function with wrong arguments:

result = announcement(True, "Python")
print(result) # True Python has been released

The function executes successfully, even though we passed a boolean True as the first argument , and a string "Python" as the second argument. To receive warnings about these mistakes, we need to use a static type-checker like mypy.

Static type-checking with mypy

We will now begin our tutorial on static type-checking with mypy to get warnings about type errors in our code.

Create a directory called type_hints and move it into the directory:

mkdir type_hints && cd type_hints

Create and activate the virtual environment:

python3.10 -m venv venv
source venv/bin/activate

Install the latest version of mypy with pip:

pip install mypy

With mypy installed, create a file called announcement.py and enter the following code:

def announcement(language, version):
    return f"{language} {version} has been released"

announcement("Python", 3.10)

Save the file and exit. We’re going to reuse the same function from the previous section.

Next, run the file with mypy:

mypy announcement.py
Success: no issues found in 1 source file

As you can see, mypy does not emit any warnings. Static typing in Python is optional, and with gradual typing, you should not receive any warnings unless you opt in by adding type hints to functions. This allows you to annotate your code slowly.

Let’s now understand why mypy doesn’t show us any warnings.

The Any type

As we noted, mypy ignores code with no type hints. This is because it assumes the Any type on code without hints.

The following is how mypy sees the function:

def announcement(language: Any, version: Any) -> Any:
    return f"{language} {version} has been released"

announcement("Python", 3.10)

The Any type is a dynamic type that’s compatible with, well, any type. So mypy will not complain whether the function argument types are bool, int, bytes, etc.

Now that we know why mypy doesn’t always issue warnings, let’s configure it to do that.

Configuring mypy for type checking

mypy can be configured to suit your workflow and code practices. You can run mypy in strict mode, using the --strict option to flag any code without type hints:

mypy --strict announcement.py

announcement.py:1: error: Function is missing a type annotation
announcement.py:4: error: Call to untyped function "print_release" in typed context
Found 2 errors in 1 file (checked 1 source file)

The --strict option is the most restrictive option and doesn’t support gradual typing. Most of the times, we don’t need to be this strict. Instead, we want to employ gradual typing to add the type hints in phases.

mypy also provides a --disallow-incomplete-defs option. This option flags functions that don’t have all of their parameters and return values annotated. This option is so handy because when you forget to annotate a return value or a newly added parameter, mypy will warn you.

To understand this, let’s add the type hints to the parameters only and omit the return value types (let’s pretend we forgot):

def announcement(language: str, version: float):
    return f"{language} {version} has been released"

announcement("Python", 3.10)

Run the file with mypy without any command-line option:

mypy announcement.py
Success: no issues found in 1 source file

As you can see, mypy does not warn us that we forgot to annotate the return type. It assumes the Any type on the return value. If the function was large, it would be difficult to figure out the type of value it returns. To know the type, we would have to inspect the return value, which is time-consuming.

To protect ourselves from these issues, we will pass the --disallow-incomplete-defs option to mypy:

mypy --disallow-incomplete-defs announcement.py

announcement.py:1: error: Function is missing a return type annotation
Found 1 error in 1 file (checked 1 source file

mypy now warns us that we omitted the return type annotation, so let’s add that:

def announcement(language: str, version: float) -> str:
    ...

Run the file again with the --disallow-incomplete-defs option enabled:

mypy --disallow-incomplete-defs announcement.py
Success: no issues found in 1 source file

This time, mypy runs without any issues because we finished annotating the function definitions.

Let’s now revisit what happened in the previous section, when we were first learning how to add type hints to functions. We noted that Python does not enforce types, and that the following code would run without any issues:

def announcement(language: str, version: float) -> str:
    return f"{language} {version} has been released"

announcement(True, "Python")  # bad arguments

Let’s see if mypy will warn us about this now:

mypy --disallow-incomplete-defs announcement.py
announcement.py:4: error: Argument 1 to "print_release" has incompatible type "bool"; expected "str"
announcement.py:4: error: Argument 2 to "print_release" has incompatible type "str"; expected "float"
Found 2 errors in 1 file (checked 1 source file)

Great! mypy warns us that we passed the wrong arguments to the function.

Now, let’s eliminate the need to type mypy with the --disallow-incomplete-defs option.

mypy allows you save the options in a mypy.ini file. When running mypy, it will check the file and run with the options saved in the file.

Create the mypy.ini file in your project root directory and enter the following code:

[mypy]
python_version = 3.10
disallow_incomplete_defs = True

In the mypy.ini file, we tell mypy that we are using Python 3.10 and that we want to disallow incomplete function definitions.

Save the file in your project, and next time you can run mypy without any command-line options:

mypy  announcement.py
Success: no issues found in 1 source file

mypy has many options you can add in the mypy file. I recommend referring to the mypy command line documentation to learn more.

You’ve now learned how to do static type-checking with mypy. Next, we will add type hints to a function without a return statement.

Adding type hints to functions without return statements

Not all functions have a return statement. When you create a function with no return statement, it still returns a None value:

def announcement(language: str, version: float):
    print(f"{language} {version} has been released")


result = announcement("Python", 4.11)
print(result)  # None

The None value isn’t helpful, as the value isn’t meant to be used. It only shows that the function has executed successfully.

To show that the function does not return anything, we can annotate the function return value with None:

def announcement(language: str, version: float) -> None:
    ...

Adding union type hints in function parameters

When a function accepts a parameter of more than one type, you can use the union character (|) to separate the types.

For example, the following function accepts a parameter that can be either str or int:

def show_type(num):
    if(isinstance(num, str)):
        print("You entered a string")
    elif (isinstance(num, int)):
        print("You entered an integer")

show_type('hello') # You entered a string
show_type(3)       # You entered an integer

The function show_type can be invoked with a string or an integer, and will log output depending on the type of value passed as the argument.

To annotate the parameter, we will use the union character |, which was introduced in Python 3.10, to separate the types as follows:

def show_type(num: str | int) -> None:
...

show_type('hello')
show_type(3)

The union | now shows that the parameter num is either str or int.

If you’re using Python ≤3.9, you need to import Union from the typing module. The parameter can be annotated as follows:

from typing import Union

def show_type(num: Union[str, int]) -> None:
    ...

Adding type hints to optional function parameters

Not all parameters in a function are required; some are optional. An example of a function that takes an optional parameter is as follows:

def format_name(name: str, title = None) -> str:
    if title:
        return f"Name: {title}. {name.title()}"
    else:
        return f"Name: {name.title()}"

format_name("john doe", "Mr")

The second argument title is an optional parameter. What makes it optional is setting the parameter title to a default value — None, in this case. The parameter can accept a string, and it can default to None if no argument is provided.

To annotate that the title is optional, we will use Optional[str] type from the typing module:

from typing import Optional

def format_name(name: str, title: Optional[str] = None) -> str:
    ...

format_name("john doe", "Mr")

We’ve now annotated the optional title parameter with the Optional type. We will add type hints to lists in the next section.

Adding type hints to lists

Python lists are annotated based on the types of the elements they have or expect to have. Starting with Python ≥3.9, to annotate a list, you use the list type, followed by []. [] contains the element’s type.

For example, a list of strings can be annotated as follows:

names: list[str] = ["john", "stanley", "zoe"]

If you’re using Python ≤3.8, you need to import List from the typing module.

from typing import List

names: List[str] = ["john", "stanley", "zoe"]

In function definitions, the Python documentation recommends that the list type should be used to annotate the return types:

def print_names(names: str) -> list[int]:
...

However, for function parameters, the documentation recommends using these abstract collection types:

  • Iterable
  • Sequence

When to use the Iterable type

The Iterable type should be used when the function takes an iterable and iterates over it.

An iterable is an object that can return one item at a time. Examples range from lists, tuples, and strings to anything that implements the __iter__ method.

You can annotate an Iterable as follows, in Python ≥3.9:

from collections.abc import Iterable

def double_elements(items: Iterable[int]) -> list[int]:
    return [item * 2 for item in items]

print(double_elements([2, 4, 6])) # list
print(double_elements((2, 4)))     # tuple

In the function, we define the items parameter and assign it an Iterable type hint, which is the followed by [int], which specifies that the Iterable contains int elements.

The Iterable type hint accepts anything that has the __iter__ method implemented. Lists and tuples have the method implemented, so you can invoke the double_elements function with a list or a tuple, and the function will iterate over them.

To use Iterable in Python ≤3.8, you have to import it from the typing module.

from typing import Iterable
...

Using Iterable in parameters is more flexible than if we had a list type hint:

def double_elements(items: list[int]) -> list[int]:
…

Tuples, or anything you can iterate over, would have to be converted into a list first and then passed as an argument.

When to use the Sequence type

A sequence is a collection of elements that allows you to access an item or compute its length.

A Sequence type hint can accept a list, string, or tuple. This is because they have special methods: __getitem__ and __len__. When you access an item from a sequence, let’s say items[index], the __getitem__ method is used. When getting the length of the sequence len(items), the __len__ method is used.

In the following example, we use the Sequence[int] type to accept a sequence that has integer items:

from collections.abc import Sequence

def get_last_element(data: Sequence[int]) -> int:
    return data[-1]

first_item = get_last_element((3, 4, 5))    # 5
second_item = get_last_element([3, 8]    # 8

In this function, we accept a sequence and access the last element from it with data[-1]. This uses the __getitem__ method on the sequence to access the last element.

As you can see, we can call the function with a tuple or list and the function works properly. We don’t have to limit parameters to list if all the function does is get an item.

For Python ≤3.8, you need to import Sequence from the typing module:

from typing import Sequence
...

We now know how to add type hints to lists. In the next section, we’ll add type hints to dictionaries.

Adding type hints to dictionaries

To add type hints to dictionaries, you use the dict type followed by [key_type, value_type]:

For example, the following dictionary has both the key and the value as a string:

person = { "first_name": "John", "last_name": "Doe"}

You can annotate it as follows:

person: dict[str, str] = { "first_name": "John", "last_name": "Doe"}

The dict type specifies that the person dictionary keys are of type str and values are of type str.

If you’re using Python ≤3.8, you need to import Dict from the typing module.

from typing import Dict

person: Dict[str, str] = { "first_name": "John", "last_name": "Doe"}

In function definitions, the documentation recommends using dict as a return type:

def make_student(name: str) -> dict[str, int]:
    ...

For function parameters, it recommends using these abstract base classes:

  • Mapping
  • MutableMapping

When to use the Mapping class

In function parameters, when you use the dict type hints, you limit the arguments the function can take to only dict, defaultDictor OrderedDict. But, there are many dictionary subtypes, such as UserDict and ChainMap, that can be used similarly. You can access an element and iterate or compute their length like you can with a dictionary. This is because they implement:

  • __getitem__: for accessing an element
  • __iter__: for iterating
  • __len__: computing the length

So instead of limiting the structures the parameter accepts, you can use a more generic type Mapping since it accepts:

  • dict
  • UserDict
  • defaultdict
  • OrderedDict
  • ChainMap

Another benefit of the Mapping type is that it specifies that you are only reading the dictionary and not mutating it.

The following example is a function that access items values from a dictionary:

from collections.abc import Mapping

def get_full_name(student: Mapping[str, str]) -> str:
    return f'{student.get("first_name")} {student.get("last_name")}'

john = {
  "first_name": "John",
  "last_name": "Doe",
}

get_full_name(john)

In the function, we add a type hint Mapping[str, str], which specifies that the student data structure has keys of type str and values of type str. We are passing dict as an argument, but a UserDict, defaultdict, OrderedDict, or ChainMap would be accepted without any issues.

If you’re using Python ≤3.8, import Mapping from the typing module:

from typing import Mapping

We now know that when we want to read a dictionary, we need to use the Mapping type hint. But when you want to mutate the dictionary, MutableMapping is more suitable.

Using the MutableMapping class

Use MutableMapping as a type hint in a parameter when the function needs to mutate the dictionary or its subtypes. Examples of mutation are deleting items or changing item values.

The MutableMapping class accepts any instance that implements the following special methods:

  • __getitem__
  • __setitem__
  • __delitem__
  • __iter__
  • __len__

The __delitem__ and __setitem__ methods are used for mutation, and these are methods that separate Mapping type from the MutableMapping type.

In the following example, the function accepts a dictionary and mutates it:

from collections.abc import MutableMapping

def update_first_name(student: MutableMapping[str, str], first_name: str) -> None:
    student["first_name"] = first_name

john = {
    "first_name": "John",
    "last_name": "Doe",
}

update_first_name(john, "james")

In the function’s student parameter, we add a type hint MutableMapping[str, str], which specifies that the student parameter has keys of type str and values of type str, and it’s going to be mutated.

In the function body, we set the first_name property value to the second argument value first_name. Changing a dictionary key value uses the __setitem__ method.

If you are on Python ≤3.8, import MutableMapping from the typing module.

from typing import MutableMapping
...

Using the TypedDict class

So far, we have looked at how to annotate dictionaries with dict, Mapping, and MutableMapping, but most of the dictionaries have only one type: str.

However, as you likely know, dictionaries can contain data more complex than strings, such as lists, nested dictionaries, or a mix of simple types such as integers, and floats.

Here is an example of a dictionary whose keys are of different types:

student = {
  "first_name": "John",
  "last_name": "Doe",
  "age": 18,
  "hobbies": ["singing", "dancing"],
}

As you can see, the dictionary values range from str, int, and list. If it only had str values, we would have annotated it as dict[str, str].

To annotate the dictionary, we will use a TypedDict that was introduced in Python 3.8. It allows us to annotate the value types for each property with a class-like syntax:

from typing import TypedDict

class StudentDict(TypedDict):
    first_name: str
    last_name: str
    age: int
    hobbies: list[str]

We define a class StudentDict that inherits from TypedDict. Inside the class, we define each field and its expected type.

With the TypedDict defined, you can use it to annotate a dictionary variable as follows:

from typing import TypedDict

class StudentDict(TypedDict):
    ...

student1: StudentDict = {
    "first_name": "John",
    "last_name": "Doe",
    "age": 18,
    "hobbies": ["singing", "dancing"],
}

You can also use it to annotate a function parameter that expects a dictionary as follows:

def get_full_name(student: StudentDict) -> str:
    return f'{student.get("first_name")} {student.get("last_name")}'

Here, we defined a function get_full_name that accepts a dictionary of type StudentDict as a parameter. If the dictionary argument doesn’t match StudentDict, mypy will show a warning.

Adding type hints to tuples

A tuple stores a fixed number of elements. To add type hints to it, you use the tuple type, followed by [], which take the types for each elements.

The following is an example of how to annotate a tuple with two elements:

student: tuple[str, int] = ("John Doe", 18)

The tuple has two elements of str and int type, so we specify both types on the tuple type. If the tuple had five elements, we would have to declare the type for each one.

The tuple type can be used as a type hint for a parameter or return type value:

def student_info(student: tuple[str, int]) -> None:
    ...

If your tuple is expected to have unknown amount of elements of a similar type, you can use tuple[type, ...] to annotate them:

letters: tuple[str, ...] = ('a', 'h', 'j', 'n', 'm', 'n', 'z')

To annotate a named tuple, you need to define a class that inherits from NamedTuple. The class fields define the elements and their types:

from typing import NamedTuple

class StudentTuple(NamedTuple):
    name: str
    age: int

john = StudentTuple("John Doe", 33)

Here, we create the StudentTuple, which is a named tuple. We then create an instance of it by passing the elements it expects.

If you have a function that takes a named tuple as a parameter, you can annotate the parameter with the named tuple:

def student_info(student: StudentTuple) -> None:
    name, age = student
    print(f"Name: {name}\nAge: {age}")

student_info(john)

Creating and using protocols

There are times when you don’t care about the argument a function takes. You only care if it has the method you want.

To implement this behavior, you’d use a protocol. A protocol is a class that inherits from the Protocol class in the typing module. In the protocol class, you define one or more methods that the static type checker should look for anywhere the protocol type is used.

Any object that implements the methods on the protocol class will be accepted. You can think of a protocol as an interface found in programming languages such as Java, or TypeScript.

Python provides predefined protocols, a good example of this is the Sequence type. It doesn’t care what kind of object it is, it only cares if it has the __getitem__ and __len__ methods. If they are defined, it accepts them.

In this section, you will define your own protocol. We will use an example of a function that calculates age by subtracting the birth year from the current year:

def calc_age(current_year: int, data) -> int:
    return current_year - data.get_birthyear()

The function takes two parameters: current_year, an integer, and data, an object. Within the function body, we find the difference between the current_year and the value returned from get_birthyear() method.

Here is an example of a class that implements the get_birthyear method:

class Person:
    def __init__(self, name, birthyear):
        self.name = name
        self.birthyear = birthyear

    def get_birthyear(self) -> int:
        return self.birthyear

# create an instance
john = Person("john doe", 1996)

This is one example of such a class, but there could be other classes such as Dog or C``at that implements the get_birthyear method. Annotating all the possible types would be cumbersome.

Since we only care about the get_birthyear() method. To implement this behavior, lets create our protocol:

from typing import Protocol

class HasBirthYear(Protocol):
    def get_birthyear(self) -> int: ...

The class HasBirthYear inherits from Protocol, which is part of the typing module. To make the Protocol aware about the get_birthyear method, we will redefine the method exactly as it is done in the Person class example we saw earlier. The only exception would be the function body, where we have to replace the body with an ellipsis (...).

With the Protocol defined, we can use it on the calc_age function to add a type hint to the data parameter:

from typing import Protocol

class HasBirthYear(Protocol):
    def get_birthyear(self) -> int: ...

def calc_age(current_year: int, data: HasBirthYear) -> int:
    return current_year - data.get_birthyear()

Now the data parameter has been annotated with the HasBirthYear Protocol. The function can now accept any object as long it has the get_birthyear method.

Here is the full implementation of the code using Protocol:

from typing import Protocol

class HasBirthYear(Protocol):
    def get_birthyear(self) -> int: ...

class Person:
    def __init__(self, name, birthyear):
        self.name = name
        self.birthyear = birthyear

    def get_birthyear(self) -> int:
        return self.birthyear

def calc_age(current_year: int, data: HasBirthYear) -> int:
    return current_year - data.get_birthyear()

john = Person("john doe", 1996)
print(calc_age(2021, john))

Running the code with mypy will give you no issues.

Annotating functions with overloading

Some functions produce different outputs based on the inputs you give them. For example, let’s look at the following function:

def add_number(value, num):
    if isinstance(value, int):
        return value + num
    elif isinstance(value, list):
        return [i + num for i in value]

print(add_number(3, 4))              # 7
print(add_number([1, 2, 5], 4))    # [5, 6, 9]

When you call the function with an integer as the first argument, it returns an integer. If you invoke the function with a list as the first argument, it returns a list with each element added with the second argument value.

Now, how can we annotate this function? Based on what we know so far, our first instinct would be to use the union syntax:

def add_number(value: int | list, num: int) -> int | list:
 ...

However, this is misleading. The code is describes a scenario in which you pass an integer as the first argument, and the function returns either a list or an int. Similarly, when you pass a list as the first argument, the function will return either a list or an int.

But this is not the accurate way to describe this, because when you pass the function int, the return value will always be an int, and there is no way a function would yield a return type of list.

Likewise, there is no way that passing a list as the first argument in the function would yield an int. It will always return a list.

To properly annotate this code, we will use function overloading. With function overloading, we will define multiple definitions of the same function without the body, add type hints to them, and place them before the main function implementations.

To do this, we will use an overload decorator from the typing module. Let’s define two overloads before the add_number function implementation:

from typing import overload

@overload
def add_number(value: int, num: int) -> int: ...

@overload
def add_number(value: list, num: int) -> list: ...

def add_number(value, num):
    if isinstance(value, int):
        return value + num
    elif isinstance(value, list):
        return [i + num for i in value]

print(add_number(3, 4))
print(add_number([1, 2, 5], 4)

We define two overloads before the main function add_number. The overloads parameters are annotated with the appropriate types and their return value types. Their function bodies contains an ellipsis (...).

The first overload shows that if you pass int as the first argument, the function will return int.

@overload
def add_number(value: int, num: int) -> int: ...

The second overload shows that if you pass a list as the first argument, the function will return a list.

@overload
def add_number(value: list, num: int) -> list: ...

Finally, the main add_number implementation does not have any type hints.

As you can now see, the overloads annotate the function behavior much better than using unions.

Annotating constants with Final

At the time of writing, Python does not have an inbuilt way of defining constants. Starting with Python 3.10, you can use the Final type from the typing module. This will mean mypy will emit warnings if there are attempts to change the variable value.

from typing import Final
MIN: Final = 10
MIN = MIN + 3

Running the code with mypy with issue a warning:

final.py:5: error: Cannot assign to final name "MIN"
Found 1 error in 1 file (checked 1 source file)

This is because we are trying to modify the MIN variable value to MIN = MIN + 3.

Note that, without mypy or any static file-checker, Python won’t enforce this and the code will run without any issues:

>>> from typing import Final
>>> MIN: Final = 10
>>> MIN = MIN + 3
>>> MIN
>>> 13

As you can see, during runtime you can change the variable value MIN any time. To enforce a constant variable in your codebase, you have to depend on mypy.

Dealing with type-checking in third-party packages

While you may be able to add annotations to your code, the third-party modules you use may not have any type hints. As a result, mypy will warn you.

If you receive those warnings, you can use a type comment that will ignore the third-party module code:

import third_party # type ignore

You also have the option of adding type hints with stubs. To learn how to use stubs, see Stub files in the mypy documentation.

Conclusion

We have come to the end of this tutorial. You should now be able to add type hints to your code with confidence. We learned about static type-checking with mypy and how to add type hints to variables, functions, lists, dictionaries, and tuples. We then learned how to use Protocols, function overloading, and how to annotate constants.

To continue building your knowledge, visit typing — Support for type hints. To learn more about mypy, visit the mypy documentation.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
Stanley Ulili I'm a freelance web developer and researcher from Malawi. I love learning new things, and writing helps me understand and solidify concepts. I hope by sharing my experience, others can learn something from them.

Leave a Reply