Python typing module – Use type checkers effectively
Python’s typing module attempts to provide a way of hinting types to help static type checkers and linters accurately predict errors.
Due to Python having to determine the type of objects during run-time, it sometimes gets very hard for developers to find out what exactly is going on in the code.
Even external type checkers like PyCharm IDE do not produce the best results; on average only predicting errors correctly about 50% of the time, according to this answer on StackOverflow.
Python attempts to mitigate this problem by introducing what is known as type hinting (type annotation) to help external type checkers identify any errors. This is a good way for the programmer to hint the type of the object(s) being used, during compilation time itself and ensure that the type checkers work correctly.
This makes Python code much more readable and robust as well, to other readers!
NOTE: This does not do actual type checking at compile time. If the actual object returned was not of the same type as hinted, there will be no compilation error. This is why we use external type checkers, such as mypy to identify any type errors.
Python typing module – Recommended Prerequisites
For using the typing module effectively, it is recommended that you use an external type checker/linter to check for static type matching. One of the most widely used type checkers in use for Python is mypy, so I recommend that you install it before reading the rest of the article.
We will be using mypy as the static type checker in this article, which can be installed by:
pip3 install mypy
You can run mypy to any Python file to check if the types match. This is as if you are ‘compiling’ Python code.
mypy program.py
After debugging errors, you can run the program normally using:
python program.py
Now that we have our prerequisites covered, let’s try to use some of the module’s features.
Type Hints / Type Annotations
On functions
We can annotate a function to specify its return type and the types of its parameters.
def print_list(a: list) -> None:
print(a)
This informs the type checker (mypy in my case) that we have a function print_list(), that will take a list as an argument and return None.
def print_list(a: list) -> None:
print(a)
print_list([1, 2, 3])
print_list(1)
Let’s run this on our type checker mypy first:
vijay@JournalDev:~ $ mypy printlist.py
printlist.py:5: error: Argument 1 to "print_list" has incompatible type "int"; expected "List[Any]"
Found 1 error in 1 file (checked 1 source file)
As expected, we get an error; since the line #5 has the argument as an int, rather than a list.
On Variables
Since Python 3.6, we can also annotate the types of variables, mentioning the type. But this is not compulsory if you want the type of a variable to change before the function returns.
# Annotates 'radius' to be a float
radius: float = 1.5
# We can annotate a variable without assigning a value!
sample: int
# Annotates 'area' to return a float
def area(r: float) -> float:
return 3.1415 * r * r
print(area(radius))
# Print all annotations of the function using
# the '__annotations__' dictionary
print('Dictionary of Annotations for area():', area.__annotations__)
Output of mypy:
vijay@JournalDev: ~ $ mypy find_area.py && python find_area.py
Success: no issues found in 1 source file
7.068375
Dictionary of Annotations for area(): {'r': <class 'float'>, 'return': <class 'float'>}
Python typing module – Type Aliases
The typing module provides us with Type Aliases, which is defined by assigning a type to the alias.
from typing import List
# Vector is a list of float values
Vector = List[float]
def scale(scalar: float, vector: Vector) -> Vector:
return [scalar * num for num in vector]
a = scale(scalar=2.0, vector=[1.0, 2.0, 3.0])
print(a)
Output
vijay@JournalDev: ~ $ mypy vector_scale.py && python vector_scale.py
Success: no issues found in 1 source file
[2.0, 4.0, 6.0]
In the above snippet, Vector is an alias, which stands for a list of floating point values. We can type hint at an alias, which is what the above program is doing.
The complete list of acceptable aliases is given here.
Let’s look at one more example, which checks every key:value pair in a dictionary and check if they match the name:email format.
from typing import Dict
import re
# Create an alias called 'ContactDict'
ContactDict = Dict[str, str]
def check_if_valid(contacts: ContactDict) -> bool:
for name, email in contacts.items():
# Check if name and email are strings
if (not isinstance(name, str)) or (not isinstance(email, str)):
return False
# Check for email xxx@yyy.zzz
if not re.match(r"[a-zA-Z0-9\._\+-]+@[a-zA-Z0-9\._-]+\.[a-zA-Z]+$", email):
return False
return True
print(check_if_valid({'vijay': 'vijay@sample.com'}))
print(check_if_valid({'vijay': 'vijay@sample.com', 123: 'wrong@name.com'}))
Output from mypy
vijay@JournalDev:~ $ mypy validcontacts.py
validcontacts.py:19: error: Dict entry 1 has incompatible type "int": "str"; expected "str": "str"
Found 1 error in 1 file (checked 1 source file)
Create user defined datatypes using NewType()
We can use the NewType() function to create new user defined types.
from typing import NewType
# Create a new user type called 'StudentID' that consists of
# an integer
StudentID = NewType('StudentID', int)
sample_id = StudentID(100)
The static type checker will treat the new type as if it were a subclass of the original type. This is useful in helping catch logical errors.
from typing import NewType
# Create a new user type called 'StudentID'
StudentID = NewType('StudentID', int)
def get_student_name(stud_id: StudentID) -> str:
return str(input(f'Enter username for ID #{stud_id}:\n'))
stud_a = get_student_name(StudentID(100))
print(stud_a)
# This is incorrect!!
stud_b = get_student_name(-1)
print(stud_b)
Output from mypy
vijay@JournalDev:~ $ mypy studentnames.py
studentnames.py:13: error: Argument 1 to "get_student_name" has incompatible type "int"; expected "StudentID"
Found 1 error in 1 file (checked 1 source file)
The Any type
This is a special type, informing the static type checker (mypy in my case) that every type is compatible with this keyword.
Consider our old print_list() function, now accepting arguments of any type.
from typing import Any
def print_list(a: Any) -> None:
print(a)
print_list([1, 2, 3])
print_list(1)
Now, there will be no errors when we run mypy.
vijay@JournalDev:~ $ mypy printlist.py && python printlist.py
Success: no issues found in 1 source file
[1, 2, 3]
1
All functions without a return type or parameter types will implicitly default to using Any.
def foo(bar):
return bar
# A static type checker will treat the above
# as having the same signature as:
def foo(bar: Any) -> Any:
return bar
You can thus use Any to mix up statically and dynamically typed code.
Conclusion
In this article, we have learned about the Python typing module, which is very useful in the context of type checking, allowing external type checkers like mypy to accurately report any errors.
This provides us with a way to write statically typed code in Python, which is a dynamically typed language by design!