Code Better with Type Hints – Part 2

By on 27 August 2021

This is the second part of a series of articles dealing with the type annotation system in Python, type hints for short. The first part gave an introduction to type hints.

This article is aimed at newcomers to type hints and wants to help you get started.

In this second part, I will go over a handful of carefully chosen examples of how to use basic type hints to solve a particular problem and improve the overall code quality and readability. Each example will cover a certain topic and look at type hints from a slightly different perspective. Most examples will end with a tip or best practice to help you improve your type hint skills!

The next article of this series will continue with more advanced uses of type hints.

First set of examples

In the first part, you have learned about static and dynamic typing and how and why to annotate a function with simple type hints. Here, I want to show you a few more examples of type hints in Python and say a word or two about why they might be useful in each case.

A quick side note about the typing module ([4]): You can always use the basic types int, float, str, bool, bytes without any import statement (see [6] for a nice cheat sheet!). This is the most basic level of type hints. More complex data types like list, dict, tuple, and set require their own type hint imported from typing. For example, typing.List is the correct type hint for the list type. However, since Python 3.9 some of these data types can be used directly as type hints, so list[int] is allowed in Python 3.9 but has to be List[int] in previous versions. You might see both, so do not get confused. You can be sure that something is a type hint when it is preceded by a colon (except for slices and dictionary definitions, but I think they are easily told apart).

My editor of choice is Visual Studio Code (VSC) and I use the Python extension with the optional Pylance language server, so you know the setup with which I recorded the screenshots. More about how to setup VSC in a later part of this series.

Better auto-completion

First, I want to advocate type hints in favor for 100% accurate auto-completion for your favorite editor. Look at this example:

VSC example no suggestion

My editor cannot provide any method suggestions for text because it is totally unclear what the type of text might be.

Though, adding a simple : str type hint to the function’s parameter solves the problem:

VSC example suggestions

Meaningful return types

The same argument holds for return types of functions, both built-in and user-defined. You will gain 100% auto-completion (and a better understanding) about what is actually returned by a function call. Look at this example:

def do_some_stuff(numbers):
    d = {x:x**2 for x in numbers}
    return d.items()

What is the return type of this function? Let Pylance tell us:

VSC example better return 1

The function do_some_stuff returns both the keys and values of a dictionary. Without Pylance and its capabilities, you would get some information about the type of the variable what_is_this, but only a very basic information of type tuple. You would not know how many elements the tuple holds nor their types. In comparison, with Pylance you get a much more detailed feedback about the proper return type of the items() method, which is ItemsView[int, int]. How very useful indeed! You know now that what_is_this holds an ItemView instance for two int elements.

If we change the function slightly to return the square root instead, Pylance provides us with a correctly updated feedback that the values of the dictionary are no longer integers but now of type float:

VSC example better return 2

No more need to read long documentations or try out some code in an interactive shell to find out what exactly is returned by a function, just let the type hints speak for themselves. However, this requires from us coders the curtesy and discipline of providing the right type hints in the first place.

Meaningful nested types

Let’s stay with this example for a little longer. So far, I have not annotated the function do_some_stuff with type hints, I have just relied on Pylance’s capability of inferring the correct types. If I were to add type hints to this function, how should I annotate the numbers parameter? Because I iterate it in line 2, it should be some kind of Iterable. If I were to annotate it with list, this would mean that you can only call this function with a list and no other iterable like range, which was used in the example.

The typing module provides all kinds of types you can use for proper type hints in more complex cases. For the case at hand, you can choose between typing.Iterable and typing.Sequence, depending on whether you want to stress __iter__ or __getitem__ being implemented by the attribute that is passed for the numbers parameter. Because the range function returns an object that implements both __iter__ and __getitem__ (so you can iterate it with for x in range(10) and access its element directly with range(10)[n], which I did not know until now!), both type hints would be appropriate. In contrast, the zip function only provides an implementation for __iter__, so typing.Iterable would be appropriate but not typing.Sequence.

Having decided to go with typing.Iterable, are you finished yet? Yes and no, depending on how specific you want to get. With Iterable you inform about (and require, if type checked) your expectation to pass an iterable object, but not what is expected of its elements. For example, strings are iterables, too, but would fail in this context. You have to explicitly require an iterable which elements are of type numbers. If you do not specify the type of the elements (or any variable, for that matter), their type hint becomes Any by default, or Unknown in the case of Pylance, which is the implicit version of Any.

So let me suggest the type hint for the numbers parameter:

from typing import Iterable

def do_some_stuff(numbers: Iterable[float]):
    d = {x:x**0.5 for x in numbers}
    return d.items()

do_some_stuff([1,2,3,4.4])

This code will not produce any problems when type checked against. But when I try to pass a list with items other than numbers, I will get a type error:

VSC example type error

I imported the Iterable type hint from the typing module and specified its elements to be of type float. The notation OuterType[InnerType] means that the outer object is some kind of container with inner objects of a certain type. However, OuterType and InnerType do not have to be different. So, when you want to type annotate a list and its elements, use list[<type>], for example list[int] for a list of integers or list[str] for a list of strings or even list[list[int]] for a nested list of integers (and remember, that, previous to Python 3.9, you have to import List from typing instead of using the native list type). If you want to type annotate a dictionary, you have to provide type hints for both the key and the values, e.g. dict[str, list] for a dictionary that uses strings as its keys and a list as its values. It’s okay to leave the type of the elements of the list undefined, but try to be as specific and strict as possible.

Resources

Resources are covering the complete article series about type hints. Not all resources were referenced in this part.

[1] PEP 484 — Type Hints

[2] PEP 483 — The Theory of Type Hints

[3] Python Type Checking (Guide) — Real Python

[4] Typing module documentation

[5] Mypy documentation

[6] Typing cheat sheet – Pysheeet

[7] Null in Python: Understanding Python’s NoneType Object — Real Python

Want a career as a Python Developer but not sure where to start?