Type-Driven Development is a technique in which we write types for a program before writing the program and then write code that satisfies the types, similar to how in test-driven development we write tests before writing code that passes the tests. It brings many benefits such as increasing robustness, accuracy, testability, readability, and extensibility of your code by taking advantage of the language’s type system.
In a previous article, we discussed the benefits of working with Type-Driven Development and the importance of static type checking at compilation time for strongly typed languages such as Haskell. Type-checking strengthens our code and reduces the number of tests we need to write. As a result, we are increasing our efficiency, whether if the paradigm is object-oriented or functional programming.
Is it possible to use a strong type system in a language like Python? The answer is yes. PEP-3107 and PEP-484 introduced Type Annotations to Python 3.5 back in 2014. It’s been a while, right? Why is this not more popular? It's because Python, being a dynamic language, doesn’t require us to write variable or return types explicitly. They’re completely optional and mostly used by editors and IDEs.
In this article, we’ll be walking through the Python type system - how to add types to our code and Docstrings and how to perform static type checking using mypy. There are plenty of options for static type-checking in Python. However, in this tutorial we will follow Guido van Rossum's suggestion.
Mypy is a third-party Python library that provides optional static type checking. Unlike other non-dynamic programming languages like Java, where the static type-checking takes place at compilation time, Mypy CLI does the type-check to a file (or a set of files) on-demand. Apart from type-checking at development time, it's helpful to also include this check automatically in the Continuous Integration pipeline.
To start using mypy, install it using any version of
pip globally or in your virtual environment:
pip install mypy
To perform static type checking on a source file:
You might know that programming languages can be statically or dynamically typed. A statically typed language does type checking at compile-time while dynamically typed language does it at run-time.
Another concept that is important to understand before we continue is the difference between weakly and strongly typed languages. In short, a strongly typed language has stricter rules such as variable assignment, return values, and function calling, while weakly typed ones can produce unpredictable results.
That being said, Python is a multi-paradigm dynamic language, so the type of variable is determined based on its value. This might be confusing, but it doesn't mean that Python is weakly typed. Python is strongly typed. Surprised? Check this out:
movie = "Die Hard" movie = movie + 2 # TypeError: cannot concatenate 'str' and 'int' objects
Now that all of that is clear, let’s begin this tutorial with a basic example:
meat = "Ground beef" print(type(meat)) # <class 'str'> weight_pounds = 0.5 print(type(weight_pounds)) # <class 'float'>
In the above example we are using the
type function to inspect the type representation of any variable. What if we include types in our first code example? Let’s check out the following example:
meat: str = "Ground beef" weight_pounds: str = 0.5
If we execute this file we expect the same output as the previous one, but we actually get the following:
error: Incompatible types in assignment (expression has type "float", variable has type "str")
Did you catch the reason why the error was thrown? The variable
weight_pounds was defined as
str, but we were assigning a float number to it. We can start spotting bugs everytime we run the type checking process.
I’m pretty sure you’ll be able to understand this function:
def make_hamburger(meat, number_of_meats): return ["bread"] + [meat] * number_of_meats + ["bread"] print(make_hamburger("ground beef", 2)) # ['bread', 'ground beef', 'ground beef', 'bread']
Let’s see how it goes with types:
from typing import List def make_hamburger(meat: str, number_of_meats: int) -> List[str]: return ["bread"] + [meat] * number_of_meats + ["bread"]
What happened here? We defined the function’s arguments types: string for
meat, integer for
number_of_meats and the return type, which is a list of string values. Did you notice the import at the beginning? Non primitive types such as
Tuple must be imported from the
You can also define your custom type names for known structures, which is very useful for improving readability. If we wanted to specify that a hamburger is a list of strings, we can define a type
Hamburger in the following way:
Hamburger = List[str] def make_hamburger(meat: str, number_of_meats: int) -> Hamburger: return ["bread"] + [meat] * number_of_meats + ["bread"]
A callable is anything you can call, using parentheses, and possibly with passing arguments as well. Callables can be functions, classes, methods, or even instances of classes (if their class implements a
Are you working with functional programming? No problem, the typing library includes a type for callables which accepts a two-dimensional list like
[[argument1_type, … argumentN_type], return_type].
from typing import Callable, Optional def sum_and_process(a: int, b: int, callback: Callable[[int, Optional[str]], bool]) -> bool: total = a + b return callback(total, None) def is_positive(val: int, message: Optional[str]) -> bool: if message: print(message) return val > 0 output = sum_and_process(5, 2, is_positive) print(output) # True
First of all, we are creating a function that takes two integer values, sums them up, and returns the output of an incoming callback. The callback
is_positive is defined as a function that takes one mandatory integer and one optional string. If the message exists it will print it, and it returns
true if the incoming value is greater than
Two of the most powerful features in a type system are generics and union types. They are also available in Python's Type Annotations. Let’s take a look at the following example:
from typing import TypeVar, List T = TypeVar("T", int, List[str]) def generic_add(x: T, y: T) -> T: return x + y x1: int = 5 y1: int = 2 print(generic_add(x1, y1)) # 7 x2: List[str] = ["Hello"] y2: List[str] = ["World"] print(generic_add(x2, y2)) # ['Hello', 'World'] x3: str = "foo" y3: str = "bar" print(generic_add(x3, y3)) # error: Value of type variable "T" of "generic_add" cannot be "str"
To avoid ourselves from repeating code, we can make use of the
Any type, but we completely lose the information for the incoming types. Instead, we defined a type variable
T that can be an integer or a list of strings and a
generic_add function that performs the “addition” operation for the incoming arguments, which are limited by the
The first two invocations will work because we're passing arguments that belong to the
TypeVar set of types, and the function will behave accordingly since both types implement the
+ operation. It will actually run for the third invocation because Python has this same operation for strings. Still, mypy will raise an error as
str is not supported by the
We can also make use of union types. Let’s suppose we want to support any number that can only be of the types integer and float:
from typing import Union Number = Union[float, int] def union_add(x: Number, y: Number) -> Number: return x + y x1: int = 5 y1: int = 2 print(union_add(x1, y1)) # 7 x2: float = 3.14 y2: float = 3.14 print(union_add(x2, y2)) # 6.28 x3: str = "2" y3: str = "1" print(union_add(x3, y3)) # error: Argument 1 to "union_add" has incompatible type "str"; expected "Union[float, int]" # error: Argument 2 to "union_add" has incompatible type "str"; expected "Union[float, int]"
Are you ready to start adding types in Python? If the answer is yes, let’s create robust and type-safe code by using Type Annotations. The first thing that you will start noticing is how the runtime bugs decrease. Please check out the official Python documentation for typings. There you can find examples for other structures such as
Generator, and inheritance.
Using static types will help us to prevent bugs and implementation issues by proving program consistency. It also allows us to use Type-Driven Development, and by doing this we decrease the amount of unit tests that we need to write. The difference with Test-Driven Development is that "unlike tests, which can usually only be used to show the presence of errors, types (used appropriately) can show the absence of errors. But although types reduce the need for tests, they rarely eliminate it entirely" (Brady, 2017, p.3).
For a deeper dive into Type-Driven Development, we suggest books like Type-Driven Development with Idris. Idris allows for more expressive types than Python, but many of the concepts in the book can be used in Python and other languages. If you are interested in sharing other ways to leverage types in Python, please feel free to reach out and share your experience with us at firstname.lastname@example.org.