Python best practices: Static typing in Python with mypy

Filip Stawicki - Backend Engineer

Filip Stawicki

14 May 2019, 8 min read

thumbnail post

What's inside

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:

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:

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:

$ 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:

$ pip install mypy

Now, let’s run it:

$ 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:

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:

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:

i = 5
reveal_type(i)  # Revealed type is 'builtins.int'

def foo() -&gt; 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:

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:

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:

class Vector:
    def __init__(self, x: int, y: int) -&gt; None:
        self.x = x
        self.y = y

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

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

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:

from typing import Generic, TypeVar

T = TypeVar('T')

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

    def translate(self, dx: T, dy: T) -&gt; 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

T = TypeVar('T')

we should write

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:

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.

Check out where you can find the best Polish programmers.

Filip Stawicki - Backend Engineer

Filip Stawicki

Backend Engineer

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

Tags

python

Share

Recent posts

See all blog posts

Are you ready for your next project?

Whether you need a full product, consulting, tech investment or an extended team, our experts will help you find the best solutions.

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 Sunscrapers website. You can change your cookie settings at any time.