Python typing module – Use type checkers effectively

Filed Under: Python
Typing Module

Introduced since Python 3.5, 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.


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 have already covered the basics of type checking in Python. You can go through this article first.

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'>}

This is the recommended way to use mypy, first providing type annotations, before using the type checker.


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)

Here, we get a static compile time error in mypy, since the name parameter on our second dictionary is an integer (123). Thus, aliases are another way to enforce accurate type checking from mypy.


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!


References


Leave a Reply

Your email address will not be published. Required fields are marked *

close
Generic selectors
Exact matches only
Search in title
Search in content
Search in posts
Search in pages