Type hinting was added to the Python standard library starting in version 3.5. Python, being a dynamically typed language, does not enforce data types. However, when argument types and return types for functions have type hints implemented, type hints can provide the following benefits:

Reviewing Old Code

Most often, developers occasionally run into old code that hasn’t been touched in months, maybe longer. Type hints make this re-familiarizing process much easier.

Catch Bugs that are Often Overlooked

Many bugs you run into with dynamic languages are only evident at runtime. A common situation is where you expect a value to be set when the value is actually None.

Better than Traditional Documentation

With traditional documentation, particularly docstrings, you come across code that gets refactored but the docstrings weren’t updated. And, there is no enforcement in which a docstring has to be in lockstep with how the code is actually working.

Code Completion

Traditionally for IDE’s, code completion has been a problem since the IDE has no way of knowing what accepted data types are appropriate when calling different functions.

Before we get into examples, it’s important to note that type checking is not done at run time because type hints are not enforced.

Static Type Checkers

In order to check our type hints, we need to run a static type checker. Remember, type hints are going to be ignored at runtime. IDE’s, like PyCharm, have type checkers built in. We can also use a Python package called mypy. Mypy is what we’ll be using in the examples below.

Before we jump in, it’s worth noting that the mypy documentation has a cheat sheet handy.

Ok, let’s go ahead and install mypy:

python -m pip install mypy

Type Hinting Variables

Type hinting variables is entirely optional. Many devs will probably opt to focus mainly on functions arguments, return values, and classes. That being said, you are free to type hit local variables if you choose to. Here are some examples of type hinting local variables:

a: int = 1
b: float = 1.0
c: bool = True
d: str = "test"
e: bytes = b"test"

Since we have mypy installed at this point, we can simply check the type hints with the following command:

mypy my_script.py

If you see no output, that means all type hints are valid. You will only see output if there’s an error. For example, we can change one of our values to an incorrect data type:

a: int = 1.0  # will result in a mypy error
b: float = 1.0
c: bool = True
d: str = "test"
e: bytes = b"test"

Above: we changed a from 1 to 1.0, which forces it to be set as a float value. Running mypy again will show the following error:

mypy my_script.py
error: Incompatible types in assignment (expression has type "float", variable has type "int")

Lists and other collection types can be type hinted as well. For this, we will have to import the typing module. In the example below, the type hint constrains x to being a list of integers:

from typing import List

x: List[int] = [1]

All collection types work similarly. You can simply import the built-in type from the typing module (Dict for dictionaries, Tuple for tuples, and so on).

Because Python lists can hold items of different types, we can use the Union type to constrain the potential data types. Consider the following list:

y = [3, 5, "test", "fun"]

Here, we have both integers and strings. If this list will only contain integers and strings, we can apply the following Union type hint:

from typing import List, Union

y: List[Union[int, str]] = [3, 5, "test", "fun"]

Type Hinting Functions

Providing type hinting for functions is what makes type hinting powerful. Consider the following function:

def get_batting_ave(hits, at_bats):
    return round(hits / at_bats, 3)

Above: to calculate a hitter’s batting average, we’re accepting 2 arguments: hits (an integer) and at_bats (an integer). In the return statement, we are dividing the 2 values, rounding the result to 3 decimal places. As a result, the function will be returning a float. To add type hints to this implementation, we can do the following:

def get_batting_ave(hits: int, at_bats: int) -> float:
    return round(hits / at_bats, 3)

Similar to the variables we were type hinting earlier, all we need to add is a colon followed by the type for each argument the function accepts. You’ll also notice we type hinted the return value, which was annotated by the arrow followed by the data type (-> float). At this point, running mypy will show that this functions type hints are valid.

There is a problem with this function, however. A potential edge case lies with players that have 0 at-bats. In this case the function would attempt to divide by 0. We can refactor the code with the following:

from typing import Union

def get_batting_ave(hits: int, at_bats: Union[int, None]) -> Union[float, None]:
    if at_bats is not None and at_bats > 0:
        return round(hits / at_bats, 3)
    else:
        return None

This is much more flexible. The Union type, as mentioned earlier, means either type in the square brackets is acceptable. Our batting average can now handle the following cases:

  • a value of 0 at-bats
  • a value of None for at-bats
  • None type will be returned if at-bats is None or 0

Since within both or our Union types, None is one of the options, we can actually simplify this further using the Optional type. In this case, Optional[float] is the same as Union[float, None].

from typing import Optional

def get_batting_ave(hits: int, at_bats: Optional[int]) -> Optional[float]:
    if at_bats is not None and at_bats > 0:
        return round(hits / at_bats, 3)
    else:
        return None

As before, we should be able to run mypy at this point and the results should be error-free.

One nice feature regarding mypy is that it will only evaluate code in which type hints are being used. So, if you’re starting to apply type hints within a large code base, it won’t complain about functions and classes that don’t have any type hints applied yet.

Consider the following:

from typing import Optional


def get_batting_ave(hits: int, at_bats: Optional[int]) -> Optional[float]:
    if at_bats is not None and at_bats > 0:
        return round(hits / at_bats, 3)
    else:
        return None


def get_slugging_pct(singles, doubles, triples, home_runs, at_bats):
    numerator = (singles + (2 * doubles) + (3 * triples) + (4 * home_runs))
    return round(numerator / at_bats, 3)

Now, we’re going to add a get_stats function that will call both of the functions above. In this walk-through, we’re going to highlight a situation where type hints can help prevent bugs that can easily go unnoticed.

from typing import Optional, Tuple


def get_batting_ave(hits: int, at_bats: Optional[int]) -> Optional[float]:
    if at_bats is not None and at_bats > 0:
        return round(hits / at_bats, 3)
    else:
        return None


def get_slugging_pct(singles, doubles, triples, home_runs, at_bats):
    numerator = (singles + (2 * doubles) + (3 * triples) + (4 * home_runs))
    return round(numerator / at_bats, 3)


def get_stats(singles: int, doubles: int, triples: int, home_runs: int, at_bats: int) -> Tuple[float, float]:
    hits = singles + doubles + triples + home_runs
    batting_ave = get_batting_ave(hits, at_bats)
    slugging_pct = get_slugging_pct(singles, doubles, triples, home_runs, at_bats)
    return batting_ave, slugging_pct

In the get_stats function, we’re returning a tuple containing the batting average and the slugging percentage. If we run mypy at this point, we will get the following error:

mypy my_script.py
error: Incompatible return value type (got "Tuple[Optional[float], Any]", expected "Tuple[float, float]")

Note that we’re calling the get_batting_ave function (from within get_stats), which is a function we type hinted earlier. Since get_batting_ave can potentially return None, we need to handle that case in get_stats return value. In order to get mypy to accept it, we need to modify the get_stats return type to Tuple[Optional[float], float] from Tuple[float, float].

...

def get_stats(singles: int, doubles: int, triples: int, home_runs: int, at_bats: int) -> Tuple[Optional[float], float]:
    hits = singles + doubles + triples + home_runs
    batting_ave = get_batting_ave(hits, at_bats)
    slugging_pct = get_slugging_pct(singles, doubles, triples, home_runs, at_bats)
    return batting_ave, slugging_pct

Re-running mypy now should result in no errors.

“Any” Type

The Any type is the most flexible type. It is completely unconstrained because it is compatible with every type. The problem with using Any is that you lose some of the benefits of type hinting. So, this should be used sparingly. For example, the code from the previous example would have worked if we switched the return type to Tuple[Any, float].

Setting a Return Type Outside a Function

We can set return types outside of functions. In our get_stats function, let’s convert the return type to a named tuple and setup a new class for it:

from typing import Optional, NamedTuple

class StatsResult(NamedTuple):
    batting_ave: Optional[float]
    slugging_pct: float

Notice that we imported NamedTuple from typing, and not namedtuple from collections. The remaining step is to apply this to our get_stats function:

from typing import Optional, NamedTuple

...

class StatsResult(NamedTuple):
    batting_ave: Optional[float]
    slugging_pct: float


def get_stats(singles: int, doubles: int, triples: int, home_runs: int, at_bats: int) -> StatsResult:
    hits = singles + doubles + triples + home_runs
    batting_ave = get_batting_ave(hits, at_bats)
    slugging_pct = get_slugging_pct(singles, doubles, triples, home_runs, at_bats)
    return StatsResult(batting_ave, slugging_pct)

Adding extra classes for the purposes of type hints can be beneficial in some cases. The one drawback, however, is they can add more noise and clutter to your code.


Posted in