Code Better with Type Hints – Part 3

By on 8 April 2022

This is the third part of a series of articles dealing with the type annotation system in Python, type hints for short. The second part discussed a set of beginner examples and highlighted the benefits of using type hints.

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

In this third part, I will continue discussing slightly more advanced examples to further deepen your knowledge about type hints. 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!

Meaningful empty variables

A variable might be initialized as an empty container or None because its actual content is assigned later in the code. For example, this might be the case for the variable holding the return value of a function. Typically, this variable is initialized right at the beginning of a function’s body and its value is assigned multiple times throughout the code. When the function’s return value is correctly type annotated, this will be no problem because you already know the type of the return value thanks to the type hint in the function header (remember the -> annotation?). However, if the return type is not properly annotated, you will not know for sure. Same, if the variable in question is not the return value but some local variable that was neither documented in the function’s docstring nor via an inline comment, you can hardly know what it is going to hold. Let’s assume you encounter the following variable assignment somewhere early in the code:

countries = {}

All you know about the variable countries is that it is a dictionary because it was initialized as such. But you have no clue about what the structure of this dictionary looks like. What is the intended purpose of this variable? Does it hold only countries? Unlikely, because it is a dictionary, so it must be intended to provide some mapping between a key and its value. The interesting question, though, is, what this relationship looks like. What are the keys and what are the values, how complex is this dictionary? You cannot tell until you find the lines of code where the variable is actually assigned a value and being used.

Now, let us help the reader by annotating this variable:

countries: dict[str, tuple[str, int]] = {}

Does this change your understanding of the variable? I hope so! With the proper type hints, you know exactly what to expect from this dictionary: That its keys will be strings (likely the name of the countries) and that its values will be two-element tuples, the first element being a string (maybe the country’s capital) and the second element being an integer (maybe the country’s population). Of course, you will not know what the types stand for just from reading the type hints, but you will gain a better understanding of how this dictionary is structured and how it will be used later in the code. And the fact that its values are tuples tells you something about the values: they are expected to be a fixed (immutable) pair of two elements, so it has to be data that is valid for all countries (like capital and population). In this example, type hints help to reduce ambiguity and uncertainty about the structure and use of variables.

This is the first example where the type annotation is done for a variable outside of a function definition. In fact, you can type annotate everything in Python, not only function parameters. However, as you interact more often with functions or methods than with their actual implementation, type hints are most valuable at places that are often visited.

Nevertheless, let me give you some more examples about variable type annotations outside of functions that might be useful.

class MyClass:
    # You can optionally declare instance variables in the class body
    attr: int
    ...

Here the type hint is useful given that the actual value for attr might be set much later, either in the __init__() method or even later. Before type hints, we used to put the type hint in a comment, but as comments are ignored by static type checkers and are not standardized, it is much better to use a type hint as that is its purpose.

Type hints are even more useful in combination with a dataclass:

from dataclasses import dataclass

@dataclass
class Point:
  x: int
  y: int

Although Python does not prevent you from initializing a Point instance with the wrong types, so Point("1", "2") will not throw an error, the dataclass decorator will use the type hint information for some internal magic like auto-generating a __init__(self, x: int, y: int) method with the right type annotations for each parameter. You can even use typing.ClassVar to express that a variable is a class variable instead of an instance variable. In this case, again, Python does not prevent you from accessing this variable through an instance (p.points instead of Point.points) but it will prevent the class variable from being added to the parameter list of the __init__ method!

from dataclasses import dataclass
from typing import ClassVar

@dataclass
class Point:
  x: int
  y: int
  points: ClassVar = 0

Meaningful type aliases

By now, you have seen a fair amount of type hints and I have to admit that readability is not always improved when types get more complicated and thus longer type hints are necessary, most of all with nested and multiple types. So, instead of accepting ever longer type hints, you can improve both readability and meaning a lot by introducing your own type names. This is possible because type annotations are just Python objects and can be assigned to variables.

Look at the following example:

Vector = List[float]
Matrix = List[Vector]
Scalar = float

def dot(v: Vector, w: Vector) -> Scalar:
  """Computes the dot product of two vectors."""
  ...

def magnitude(v: Vector) -> Scalar:
  """Returns the magnitude (or length) of a vector."""
  ...

def shape(A: Matrix) -> tuple[int, int]:
  """Returns (no. rows, no. columns) of a matrix."""
  ...

def identity_matrix(n: int) -> Matrix:
  "Returns the n x n identity matrix."""
  ...

By introducing the speaking names Vector, Matrix and Scalar as new types, the type hints not only provide information about their parameters but also inform about the domain and build a coherent domain language. Now you are able to think of the dot function no longer as a function that takes two list of floats and produces a single number but as an operation between two vectors that produces a scalar. And the magnitude of a vector is, again, a scalar, representing its length. And because the shape of a matrix has two elements it follows that a matrix must be some object that has two dimensions.

By assigning new and meaningful names to existing type definitions you can further increase the readability and comprehensibility of your code and better communicate your intends to the reader.

Update for Python 3.10: Since Python 3.10 (and thanks to PEP 613) it is possible to use the new TypeAlias from the typing module to explicitly declare a type alias. This solves some problems with the old syntax, which, strictly speaking, is a global expression and is only interpreted as type alias if used correctly. With TypeAlias , you can rewrite the example to

from typing import TypeAlias

Vector: TypeAlias = list[float]
Matrix: TypeAlias = list[Vector]
Scalar: TypeAlias = float

This requires an additional import, which might feel like a burden, but it increases readability, clearly states your intent and improves the capability of static type checkers.

Avoid confusion about multiple types

If you need to allow multiple types, you can use typing.Union, e.g. Union[str, int] which tells the reader that a variable can be either a string or an integer. This might be useful whenever it is not possible to require a single type or you want to provide some convenience to the users of your function or class. However, it is best to avoid multiple types in favor of clarity and to use other mechanism like single dispatch or a more general variable type like a dictionary.

Look at the following example:

def send_email(address: Union[str, List[str]], body: str):
  ...

It seems that the authors of the send_email function wanted to be less strict about the type of the address parameter. Instead of always requiring it to be a list, they also foresaw the possibility or necessity to allow a single string. We can immediately understand this type hint as follows: When there is only a single address you want to send an email to, you can pass this address to the function as it is, but if you have multiple addresses, you can also pass the whole list and the function will take care of it. Nice to have this information directly encoded in a type hint, isn’t it?

If you wonder how your favorite editor handles the case of multiple types, at least for VSC I can tell you that it’s clever and suggests all methods for both strings and lists. Not what I was expecting but certainly appreciated!

Avoid confusion about None

None is one of our best friends and can be really nasty at the same time! It is best practice to initialize optional parameters with a default value of None so you can check against None before you use the attribute and avoid the problem of some nasty bugs, e.g. when using an empty list as default (see [7]). However, this does not prevent the user of your function from passing None to any of the parameters, even the required ones! This happens regularly, mostly when chaining functions and passing through return values. So what can you do to spot this? Use typing.Optional when you want to allow None, otherwise it will be a type error.

Let’s return to the example of sending an email, this time a little mote elaborated:

def send_email(
    address: Union[str, List[str]],
    sender: str,
    cc: Optional[List[str]],
    bcc: Optional[List[str]],
    subject="",
    body: Optional[List[str]]=None,
) -> bool:
    ...

There are a few parameters that have an Optional type hint, namely cc, bcc and body. With no type hints at all, a static type check would not give you any error when passing None to any of the parameters, because by default a parameter without a type hint has the type Any, which is also fulfilled by None. When adding a type hint without Optional, None is no longer a valid option and your type checker will inform you about the incompatible type. So for example when passing None as value for sender you get: Argument 2 to send_email has incompatible type None; expected str.

To explicitly allow for None you can add Optional to the type hint, which allows both the actual type and the special value None. Now, calling the function with None for the body parameter is not a problem any more because Optional[List[str]] is a shortcut for Union[List[str], None] and thus allows for None values.

A slightly different example is the following: Imagine you want to call the max function with some list of numbers. However, some of the numbers might actually be None values due to some error during the recording process (e.g. sensor values). This is a problem because the max function does not handle None values well and instead throws a TypeError.

>>> max([1,None,3])
TypeError: '>' not supported between instances of 'NoneType' and 'int'

This is hard to see without a static type checker. However, if there is a variable list_of_numbers that is correctly annotated with List[Optional[float]], indicating that some numbers might be missing, Pylance will inform you about a problem with passing this variable to the max function: “No overloads for max match the provided arguments Argument types: (list[float | None]).

VSC max error

So type hints successfully prevented you from running into a runtime error because of a hidden None value in some of the attributes.

This concludes the third part of this series. You should have learned about annotating not yet fully initialized variables, instance and class variables and how to deal with multiple types at once as well as the special None value. I think this are enough examples, so the next part of this series will deal with how to actually check type hints.


I hope you have enjoyed reading the article! If you have any questions, comments or suggestions, don’t hesitate to contact me via the PyBites Slack, LinkedIn or GitHub. You can also leave your comment right on this page.

Let me know if you want to read more about this topic or any other topic, for that matter.

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?