Static typing is an approach to writing code that allows developers to specify the type of variables and return type of functions before the code is actually run. By design, Python is a dynamically typed language and uses duck typing to determine the variable type (If it walks like a duck and it quacks like a duck, then it must be a duck). This makes the language more flexible.


However, that flexibility may become a drawback in larger codebases. PEP 484 introduced a new syntax for specifying types, and with the help of static type checkers such as mypy developers can take advantage of static typing in Python.

Read this article for some handy tips to help you master static typing for quality Python development.


Here’s a basic example of static typing in Python

Let’s take a look at this simple piece of code:

1
2
3
4
5
def add(x, y):
  return x + y

print(add(10, 20))
print(add(3.5, 'abc'))

In this example, it’s clear what types of x and y can be used. But in more complex functions, it might not be so obvious.

We could use docstrings to indicate what types are expected, but typehints offer a more concise and standardized syntax:

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

print(add(10, 20))print(add(3.5, 'abc'))

In this example, we declare that both x and y are of type int, and the function also returns int. Let’s go ahead and try it:

1
2
3
4
5
6
7
8
$ python3 foo.py
30
Traceback (most recent call last):
  File "abc.py", line 5, in <module>
    print(add(3.5, 'abc'))
  File "abc.py", line 2, in add
    return x + y
TypeError: unsupported operand type(s) for +: 'float' and 'str'

What happened here? We can still pass other types than int and the code will run. That’s because typehints are just that: hints.

To check whether the provided arguments are of the correct type, we need a tool called a static type checker – for example, mypy. Let’s go ahead and install it:

1
$ pip install mypy

Now, let’s run it:

1
2
3
$ mypy foo.py
foo.py:5: error: Argument 1 to "add" has incompatible type "float"; expected "int"
foo.py:5: error: Argument 2 to "add" has incompatible type "str"; expected "int"

mypy is already telling us where we made a mistake. We passed float instead of int as the first argument, and str instead of int as a second argument.


Why use static typing in Python?

Shortcuts

As typehinting in Python becomes increasingly popular, it’s used in the standard library (and other libraries) to simplify code or avoid boilerplate. Take a look at following code:

1
2
3
4
5
6
class User:
    def __init__(first_name, last_name, date_of_birth, number_of_children=0):
        self.first_name = first_name
        self.last_name = last_name
        self.date_of_birth = date_of_birth
        self.number_of_children = number_of_children

To avoid such a scenario, we can use dataclasses which make use of typehints:

1
2
3
4
5
6
7
8
9
10
11
from dataclasses import dataclass
from datetime import date
from typing import Optional


@dataclass
class User:
    first_name: str
    last_name: str
    date_of_birth: date
    number_of_children: int = 0

That way, we avoid writing boilerplate code and get typehints out-of-the-box.


IDE integration

Modern IDEs are smart enough to understand typehints. Thanks to that capacity, we get better code completion and reduce the chance of human error. Moreover, if you’re not familiar with the codebase, typehints can help in remembering which properties an object has or what the return type of a function is.

Self-documenting code

Types can be specified in docstrings. But it’s easy to forget about updating documentation after applying changes to the code. Typehints are part of the code, so maintaining type documentation is no longer an issue. Even if we forget to change the typehints, mypy will usually catch and report that.


Gradual integration

By default, mypy omits bodies of functions that don’t specify the types of arguments or return type. That allows implementing typehints gradually into the existing codebase without having to go through all the code at once.


Fewer tests to write

Using static type checker allows narrowing down the possible inputs to a function. There’s no need to test how a function behaves depending on the types of passed arguments or whether we pass correct arguments anywhere in the code. The static type checker does all of that for us.


Some advanced stuff

mypy is still in beta version but it’s widely used and actively developed. It already handles a lot of scenarios when it comes to static type checking. Here’s a brief overview of these scenarios.


Type interference

When a new variable is instantiated and no type has been explicitly specified for it, mypy does its best to determine the strictest type possible based on the information it gets. Consider the following example:

1
2
3
4
5
6
7
8
i = 5
reveal_type(i)  # Revealed type is 'builtins.int'

def foo() -> float:
    return 3.5

f = foo()
reveal_type(f)  # Revealed type is 'builtins.float'

mypy knows that 5 is an int, so it associates the variable i with the type of int. Using it in an inproper context (for example, when trying to add a string to it) will cause an error when mypy is run. Accordingly, mypy knows that foo() returns a float, so the type of f must also be float.


Python’s specific syntax is also no challenge for mypy:

1
2
3
4
5
x, y, *z = 1, 3.5, 'a', 'b', 'c'

reveal_type(x)  # Revealed type is 'builtins.int'
reveal_type(y)  # Revealed type is 'builtins.float'
reveal_type(z)  # Revealed type is 'builtins.list[builtins.str*]

When it comes to containers, mypy tries to get the closest common ancestor of all of the elements. Here we create classes B and C, both of which derive from class A. Take a look at how mypy handles a list or dict containing instances of both B and C:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
l = [1, 2, 3]
reveal_type(l)  # builtins.list[builtins.int*]
j = [1, 3.5, 'abc']
reveal_type(j)  # builtins.list[builtins.object*]

class A:
    pass

class B(A):
    pass

class C(A):
    pass

k = [B(), C()]
reveal_type(q)  # Revealed type is 'builtins.list[foo.A*]'

d = {
    'b': B(),
    'c': C(),
}
reveal_type(d)  # Revealed type is 'builtins.dict[builtins.str*,


Generics

Generics are a powerful tool for writing statically-typed, yet flexible code. Let’s try to write a simple vector class with some basic operations:

1
2
3
4
5
6
7
8
class Vector:
    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y

    def translate(self, dx: int, dy: int) -> None:
        self.x += dx
        self.y += dy

Everything is fine until we try to use another numeric type:

1
v = Vector(2.5, 3.5)  # error: Argument 1 to "Vector" has incompatible type "float"; expected "int"

Do we have to redefine Vector for every type that supports the += operator? No, that’s exactly what generics are for!

Generics allow writing classes (or functions) that rather than specifying a type let the user of the class (or function) to do so. Let’s rewrite Vector class using generics and run mypy on it:

1
2
3
4
5
6
7
8
9
10
11
12
from typing import Generic, TypeVar

T = TypeVar('T')

class Vector(Generic[T]):
    def __init__(self, x: T, y: T) -> None:
        self.x = x
        self.y = y

    def translate(self, dx: T, dy: T) -> None:
        self.x += dx  # error: Unsupported left operand type for + ("T")
        self.y += dy  # error: Unsupported left operand type for + ("T")

If you think about it, it makes sense that mypy signals an error. Right now, we can substitute any type in place of T, but not every type supports += operator. Thankfully, mypy has a feature that allows us to limit the types with which T can be substituted. Instead of

1
T = TypeVar('T')

we should write

1
T = TypeVar('T', int, float, Decimal)

This means: T can be any type that is, or inherits from int, float or Decimal. Let’s try this out:

1
2
3
4
5
6
7
8
decimal_vector = Vector(Decimal('1.23'), Decimal('2.5'))
decimal_vector.translate(Decimal('1'), Decimal('2'))  # OK!

int_vector = Vector(2, 5)
int_vector.translate(1, 2)  # OK!

decimal_vector.translate(1, 2)  # error: Argument 1 to "translate" of "Vector" has incompatible type "int"; expected "Decimal"
int_vector.translate(Decimal('1'), Decimal('2'))  # error: Argument 1 to "translate" of "Vector" has incompatible type "Decimal"; expected "int"

After restricting which types T can be substituted with, we can finally use our Vector class with all the numeric types Python provides.


I hope these tips help you make the most of static typing in Python. For more Python best practices, check out other articles on this blog under the Python category.

Filip Stawicki
Filip
Backend Engineer

Filip is a backend Python engineer at Sunscrapers. He specializes in developing web applications using Python and Django. Apart from software architecture, his passions are speedway and cycling.

Python

Elasticsearch with Python: 7 tips and best practices

Elasticsearch is an open-source distributed search server that comes in handy for building applications with full-text search capabilities. While its core implementation is in Java, it provides a REST [...]

Python

6 expert tips for building better Django models

As the old programming adage goes: Show me your algorithm, and I will remain puzzled but show me your data structure, and I will be enlightened. An application’s data [...]

Join our newsletter.

Scroll to bottom

Hi there, we use cookies to provide you with an amazing experience on our site. If you continue without changing the settings, we'll assume that you're happy to receive all cookies on the Sunscrapers website. You can change your cookie settings at any time.

Learn more