Why I started using Python type annotations – and why you should too

Type hints add optional static typing to Python 3.5+, and I love them. I now use annotated variables throughout my projects.

Florimond Manca,

When Python 3.6 was released back in december 2016, I was excited at some of the new features it provided. Among them, f-strings were quite a highlight for me (if you don't use them yet, start today!).

Some time after I upgraded my stack to use Python 3.6, I heard about type annotations thanks to a video from Dan Bader. He explained what type annotations were and why he found them useful.

Although they were introduced in Python 3.5 (back in 2015), I have only started using type annotations a few months ago.

Because they help me and my peers write more readable, elegant and arguably plain better code, I now use type annotations in all my projects.

I figured now was a good time to also share my experience!

Static typing in Python?! Nope, not for me

When I first heard of type annotations, I was not convinced. I thought type annotations were some sort of hack of the Python language itself.

The idea of specifying types while using a dynamically typed language seemed plain odd to me, given that I had been doing great for years with Python's dynamic nature.

At the time, I was perfectly fine with writing code like:

def set_pos(self, pos):
    self.x = pos[0]
    self.y = pos[1]

What should pos contain? Well, that's obvious β€” just look at the code and you can immediately tell it should be a tuple containing two numbers (what kind of number? Integers? Floats?).

I also learned that type annotations were actually not used by Python's runtime at all. They were completely ignored. So I thought β€” what use if it has no influence on the execution of the code I write?

I really couldn't grasp all the growing fuss about introducing static typing into Python.

Until you start working with other devs

I began working as a software engineering intern at ComplyAdvantage in March 2018. I have been lucky enough that this startup treats their interns with the same level of trust and responsibilities as their actual employees. As such, I have been working on challenging, real-life projects since then.

In fact, it's been my very first work experience as a developer. I never had to read much of other people's code before, and they never really had to read mine. And you guessed it β€” Python is all the rage at ComplyAdvantage.

One of the first pieces of code I read was written by my mentor. He's one of those guys with a strong Java background who also picked up Python for their job β€” and are doing great at it. Coming from the world of statically typed languages, he was a strong advocate of Python type annotations, and he used them all over his code. When I asked why, he roughly said to me:

It's just clearer for everyone. Type annotations tell other people what your code's inputs and outputs are β€” and they don't even have to do the effort of asking.

The fact that this statement focuses so much on other people struck me.

He was basically saying that one uses type annotations to help others understand their code more easily.

Readability counts

Think about it for a moment. When you work on a project, the code you write may make a lot of sense to you right now. You don't feel the need to document it too much.

But other people (including the 6-months-from-now version of yourself) will in the future have to read your code and understand what it all means. As I learnt from a colleague, they'll need to answer at least three basic questions:

  1. What does this piece of code take as an input?
  2. How does it process the input?
  3. What does it produce as an output?

As I was reading more and more of other people's code β€” including complex legacy code β€” I realized that type annotations were actually extremely useful. They were helping me to answer questions 1/ and 3/ at light speed. (Question 2/ could be answered just as easily with meaningful function names.)

Let's play a quick game

I have written below a function whose body has been hidden. Can you tell me what it does?

def concat(a, b):
    ...

Here's my take β€” from the function name, I would say that concat() takes two lists (or tuples?) and concatenates them to return a single list containing the elements of a and b.

Obvious, right? Not really.

There are actually other possibilities. What if concat() actually simply concatenates two strings, for example?

The thing is β€” we don't really understand what concat() does because we cannot answer all three questions from above. Only can we roughly answer question 2/ by: "it does some sort of concatenation".

Now, let's add type annotations to concat():

def concat(a: int, b: int) -> str:
    ...

Ah-ha! We were actually wrong in both our guesses. It seems concat() takes two integers and outputs a string.

So I would now say β€” it takes two integers as an input, turns them into their string representation, concatenates them and returns the result.

And that's exactly what it does:

def concat(a: int, b: int) -> str:
    return str(a) + str(b)

This example shows you that knowing the inputs and outputs is crucial to understanding a piece of code. And type annotations help you let your readers know almost instantly.

There used to be a workaround

Reflecting back on my experience, it turns out I already knew this β€” way before I started using type annotations β€” and probably so did you.

I always loved writing clean code and documenting it was well as I could. I believe it is good discipline to add a docstring on all your functions and classes to explain what they do (functionality) and why they even exist (purpose).

Here is an actual code snippet from a personal project I worked on a few years ago:

def randrange(a, b, size=1):
    """Return random numbers between a and b.

    Parameters
    ----------
    a : float
        Lower bound.
    b : float
        Upper bound.
    size : int, optional
        Number of numbers to return. Defaults to 1.

    Returns
    -------
    ns : list of float
    """
    ...

Let's see… a docstring that describes parameters, along with their types, and the output value along with its type…

Woah.

In a sense, I was already using type annotations β€” via docstrings.

Don't get me wrong: documenting your code with docstrings is great and useful when the component has a lot of logic. There are standard formats (I used the NumPy doc format above) which are helpful as they ensure documentation conventions and can be interpreted by some IDEs as well.

However for simple functions, using a full-blown docstring just to describe arguments and return values sometimes felt like a workaround β€” for the fact that Python did not offer any notion of type hinting whatsoever (or so I thought).

Type annotations can sometimes replace a docstring altogether as they are β€” in my opinion β€” a very clean and simple way of documenting inputs and outputs. In the end, your code is easier to read both for you and your fellow developers.

But wait! There's more

Type annotations were added to Python 3.5 along with the typing module.

This module provides ways to annotate all kinds of types (like lists, dictionnaries, functions or generators) and even supports nesting, generics and the ability to define your own custom types.

I won't dive into the details of the typing module, but I just wanted to share something I discovered not so long ago: type annotations can be used to generate code.

I'll take the example of namedtuple. This is a data structure from the collections module β€” just like ChainMap which we already covered in A practical usage of ChainMap in Python.

What namedtuple does is generate a class whose instances behave like tuples (they are immutable) but allow attribute access via dot notation.

A typical usage of namedtuple is the following:

from collections import namedtuple

Point = namedtuple("Point", "x y")
point = Point(x=1, y=5)
print(point.x)  # 1

If we remember what we said about the importance of documenting inputs and outputs, there's something missing here. We don't know what the types of x and y are.

It turns out that the typing module has an equivalent of namedtuple called NamedTuple which allows you to use type annotations.

Let's redefine the Point class with NamedTuple:

from typing import NamedTuple


class Point(NamedTuple):
    x: int
    y: int


point = Point(x=4, y=0)
print(point.x)  # 4

I *love* this. Beautiful, clean and explicit Python code.

Note that the usage of Point is exactly the same as before, except we now benefit from more readable code β€” and our IDEs and editors can help us detect potential type errors as well thanks to static checkers such as MyPy (and their various integrations).

There are many more exciting things you can do with type annotations, especially since they are now a core part of the Python language.

For instance, Python 3.7 has introduced data classes, an exciting new way of generating classes for simple yet efficient data storage. However, they would be worth their own blog post entirely, so I'll keep them for later.

What about philosophy?

Python was designed as a dynamic programming language, and we're now introducing static typing to it. At this point, some may wonder:

How can this fit the language's philosophy?

Have the Python core developers just realized that dynamic typing was a mistake?

Well, not really. Try searching for python philosophy on Google and here's what you'll get:

PEP 20 - The Zen of Python

The Zen of Python drives the philosophy of the langage as a whole.

In my opinion, type annotations are 100% aligned with Python's philosophy. Here are some aphorisms they embody perfectly.

Explicit is better than implicit.

This is basically the reason why type annotations were invented in the first place. Just compare:

def process(data):
    do_stuff(data)

and:

from typing import List, Tuple


def process(data: List[Tuple[int, str]]):
    do_stuff(data)

Simple is better than complex.

In simple cases, using type hinting is way simpler than resorting to full-blown docstrings.

Readability counts.

Well, we've already discussed this one. πŸ˜‰

There should be one β€” and preferably only one β€” obvious way to do it.

This is implemented with the strict (yet simple) syntax for type annotations. They are the go-to if you want to document and support static types in Python!

And lastly…

Now is better than never

To me, type annotations were a game changer:

  • They improved the way I write code.
  • By providing a standard way to document inputs and outputs, they help you and other people understand and reason about code much more easily.
  • They also enable new ways of writing code in a cleaner and more concise way.

If you're not using type annotations yet: start now! There is a lot of great content about them out there to get started.

If you're already using β€” and hopefully digging β€” type annotations: help spread the love! Write about it, share it with your colleagues and fellow developers.

Type annotations are incredible and now a core piece of the Python language. Let's use more of those! πŸ’»