A Dive into Python Type Hints

What are Type Hints?

Type hints allow us to better document the inputs and outputs of our code by labelling the types we expect.  They look like this:

# No type hints
def join(separator, items):
	return separator.join(items)
    
# Type hints
def join(separator: str, items: list) -> str:
	return separator.join(items)

Above we are specifying the function will take 2 parameters, 1 a string, 1 a list, and will return a string.

They were introduced in Python 3.5, with multiple updates since to the typing module in the standard library.

Important things to note about type hints:

  • They are not enforced at build or run time in any way by Python
  • Instead used by type checkers / linters (like mypy or pyright) and IDEs

Type hints can help us catch errors at development time, make the intention of our code clearer and improve our documentation.

Checking Types

If you are using JetBrain's PyCharm, by default it will give you warnings when types don't line up.

VS Code with the Python & PyLance extensions have type checking off by default, enable in the PyLance extension settings by setting the “Type Checking Mode” to basic or strict.

From command line and for CI you can use the mypy or pyright packages.

e.g: running  mypy .\type_hints\main.py

Part 1: Going Way Overboard On An "Add" Function

No Type Hinting

This is the function we’ll be using to show various types of type hinting:

def add(left, right):
    return left + right

Hinting with Built In Types

Our first example shows the type hinting syntax, this function takes 2 integers and returns an integer:

  • parameter_name: type - for inputs
  • -> type - for return values
def add_1(left: int, right: int) -> int:
    return left + right

You can add type hints to variables too:

i: int = 0

The issue with the above code however is that it is too specific; lots of types support + behaviour.

Yet as we’ve added int type hints we’ll get a warning if we try to add strings, floats, lists and so on, even though the code would happily add those types together.

Using the Any Type Hint

We could use the Any type from the typing module, which specifies that we’ll accept literally any type, including None, into the method.

from typing import Any

def add_2(left: Any, right: Any) -> Any:
    return left + right

This presents two issues though:

  1. It’s not really any better than having no type hints, it doesn’t tell us much about the code
  2. Plenty of types don’t support the + operator (such as a dict) so we aren’t catching a possible error here

Using the Union Type Hint

If you want specific a set of types that can be accepted, Union allows you to do that.

Here we’re saying we will accept either an int, or float, or str for each parameter.

from typing import Union

def add_3(left: Union[int, float, str], right: Union[int, float, str]) 
	-> Union[int, float, str]:
    return left + right

This is a little unwieldy though, we have to declare the same Union 3 times.

Instead we can make an alias and reuse it:

from typing import Union

AddTypes = Union[int, float, str]

def add_4(left: AddTypes, right: AddTypes) -> AddTypes:
    return left + right

Union helps us declare a set of types, however there are many more types that support + and we don’t really want to list all of them.

Also, what happens if create our own types that support add (by implementing the __add__ method)?

We would have to go and update the type alias for each class that does this.

Using the Protocol Type Hint

Protocol allows us to specify a type that implements certain behaviours.

Similar to an interface, however the types don’t have to explicitly say they implement the interface.

Here we’re saying that the add function will take in any type that implements the __add__ method.

from typing import Any, Protocol

class SupportsAdding(Protocol):
    def __add__(self, added_to) -> Any: ...

def add_5(left: SupportsAdding, right: SupportsAdding) -> Any:
    return left + right

Now all these will pass type checking, as int, str and list all implement the __add__ method.

If we were to create a new type that implemented that method, it would pass the checks too.

add_5(1, 2)
add_5("a", "B")
add_5([], [])

A dictionary will fail, as it doesn’t implement an __add__ method.

add_5({"k": 1}, {"k": 2})

Generic Protocols with TypeVar

We can improve this further by restricting it down to the types the __add__ method should use.

As these are dependent on the implementing type, we need to use generics.

Here we are saying that the implemented __add__ method must only add and return the type of the class implementing it.

from typing import Any, Protocol, TypeVar

T = TypeVar("T")

class SupportsAdding(Protocol[T]):
    def __add__(self, x: T) -> T: ...

def add_6(left: SupportsAdding, right: SupportsAdding) -> SupportsAdding:
    return left + right

Part 2: Other Type Hints Available

Collection Type Hints

There is support for collections in the typing module, for example Dict, List, Set, Tuple:

from typing import List, Set

# Takes any list, regardless of the types inside it
def collection_add(first: List, second: List) -> List:
    return first + second

# Only accepts lists containing strings
def string_collection(items: List[str]) -> List[str]:
    return items

# Accepts lists which contain strings, or integers, or both
def bits_n_bobs(items: Set[Union[str, int]]) -> Set[Union[str, int]]:
    return items

Dictionary and Tuple Type Hints

Can specify the key and values types in a dictionary, and all value types in a tuple:

from typing import Dict, Set, Tuple

# Dictioary with a string key and integer values
def dictionary(items: Dict[str, int]) -> None:
    for k, v in items.items():
        print(f"{k} = {v}")

# Tuple where the first value is a string
# the second is an integer and third is a Set of integers
def tupling(my_tuple: Tuple[str, int, Set[int]]) -> None:
    name, age, ids = my_tuple
    print(f"{name}, {age}, {ids}")

Higher Order Function Hinting

The typing module also supports defining the shape of a function passed in as a parameter through the Callable type hint:

from typing import Callable

# This function takes another function as a parameter, then executes it
def logging_wrapper(func: Callable):
    print("Starting")
    func()
    print("Ending")

# This function takes another function as a parameter
# The parameter function must take a string and integer as parameters
# Then must return a string.
def output(func: Callable[[str, int], str]) -> str:
    return func("A", 1)

Optional Type Hints

For functions where None can be returned, you could use Union[Type, None], however that can be unwieldy.

Instead there is the Optional type that specifies a value could be either the type or None.

from typing import List, Optional

T = TypeVar("T")
def get_first_item_or_none(items: List[T]) -> Optional[T]:
    return items[0] if items else None

End

Hopefully this was a useful overview of type hinting and the options available in Python from 3.5 onwards.

If you have any questions, thoughts or corrections for the article please feel free to ping me on Twitter here: @LukeAMerrett.

Header image credit: Maëlick In The North