Python Type Hints - How to Use typing.Literal

This scraper is apparently the kind of tool a typographer would use.

To put it tautologically, type hints normally specify the types of variables. But when a variable can only contain a limited set of literal values, we can use typing.Literal for its type. This allows the type checker to make extra inferences, giving our code an increased level of safety.

In this post we’ll look at how to use Literal and the benefits it provides.

Por Exemplo

To use Literal, we simply parametrize it with the allowed literal values:

from typing import Literal

game: Literal["checkers", "chess"]

We’ve declared that the game variable has only two possible values: the strings "checkers" and "chess".

Allowed Types

typing.Literal was defined in PEP 586, which defined the allowed types for values:

Additionally, we can nest Literals, which combines their values. For example Literal[Literal[1, 2], Literal[3], 4] is equivalent to Literal[1, 2, 3, 4].

Benefits

When we use a Literal type, the type checker can ensure that:

  1. Assignments use permitted values.
  2. Function calls use permitted values.
  3. Comparisons use permitted values.
  4. Conditional blocks use the subset of values they compared against, via specialized type narrowing.
  5. Chained if/elif statements use all permitted values, when we use exhaustiveness checking.

Let’s examine those in turn.

1. Assignments

Imagine we make a typo and assign an incorrect value to our game variable:

from typing import Literal

game: Literal["checkers", "chess"]

game = "chuss"

Mypy will spot this for us:

$ mypy example.py
example.py:5: error: Incompatible types in assignment (expression has type "Literal['chuss']", variable has type "Union[Literal['checkers'], Literal['chess']]")
Found 1 error in 1 file (checked 1 source file)

2. Function calls

Similarly, say we pass an unsupported value to a function using Literal:

from typing import Literal


def get_game_count(game: Literal["checkers", "chess"]) -> int:
    ...


get_game_count("chockers")

Mypy will also find this:

$ mypy example.py
example.py:8: error: Argument 1 to "get_game_count" has incompatible type "Literal['chuss']"; expected "Union[Literal['checkers'], Literal['chess']]"
Found 1 error in 1 file (checked 1 source file)

3. Comparisons

We might also use an unsupported value in a comparison against a Literal value:

from typing import Literal

game: Literal["checkers", "chess"] = "checkers"

if game == "owela":
    ...

In this case, as long as we have Mypy’s strict_equality option active, it will find the error:

$ mypy --strict-equality example.py
example.py:5: error: Non-overlapping equality check (left operand type: "Union[Literal['checkers'], Literal['chess']]", right operand type: "Literal['owela']")
Found 1 error in 1 file (checked 1 source file)

Note that, at time of writing, Mypy only performs strict equality checks for == and !=. A more complicated comparison, like game in ["owela"], will not fail the strict equality check.

4. Conditional block type narrowing

When we make a comparison against a variable’s type, Mypy can perform type narrowing to infer the variable has a restricted type within the conditional block. We previously covered how to do this with constructs like if isinstance(...).

When we compare a Literal against one or more values, Mypy will also perform type narrowing. It can infer the value has a more limited Literal type within the conditional block. For example, take this code:

from typing import Literal

game: Literal["checkers", "chess"] = "checkers"

if game == "checkers":
    reveal_type(game)
else:
    reveal_type(game)

When we run Mypy on it, the reveal_type() debug calls show us the narrowed Literal types:

$ mypy --strict-equality example.py
example.py:6: note: Revealed type is "Literal['checkers']"
example.py:8: note: Revealed type is "Literal['chess']"

5. Exhaustiveness Checking

Exhaustiveness checking is when the type checker ensures we cover all possible options for a variable’s type or value.

Python type hints have no formal specification for exhaustiveness checking yet, but we can emulate it with the NoReturn type. If we use a function that accepts NoReturn as a value type, any call to it will fail, since NoReturn matches no type.

This technique was documented for Enum types in a blog post by Haki Benita. We can also use it with Literal types.

Imagine we forgot to handle the "chess" case in our get_game_count() function:

from typing import Literal, NoReturn


def assert_never(value: NoReturn) -> NoReturn:
    """Exhaustiveness checking failure function"""
    assert False


GameType = Literal["checkers", "chess"]


def get_game_count(game: GameType) -> int:
    if game == "checkers":
        count = 123
    else:
        assert_never(game)
    return count

Mypy reports:

$ mypy example.py
example.py:16: error: Argument 1 to "assert_never" has incompatible type "Literal['chess']"; expected "NoReturn"
Found 1 error in 1 file (checked 1 source file)

The error message is not particularly clear, as we’re only emulating exhaustiveness checking. But it does report the unhandled value is Literal['chess'], and the approximate line to correct that on.

Exhaustiveness checking is particularly useful when we introduce a new value into our system. We can add the value to our first shared Literal definition, and then use Mypy to find places that need updating.

Fin

I hope that you’ve literally enjoyed this post,

—Adam


If your Django project’s long test runs bore you, I wrote a book that can help.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: ,