Python Type Hints - How to Split Types by Python Version

Tweezers, check. sys.version_info, check.

The typing module continues to evolve, with new features in every Python version. This can make it tricky if you’re trying to type code that supports multiple Python versions. To help write such code, Mypy identifies version checks using sys.version_info and reads the appropriate branch.

Fall Back to a Simpler Type

Imagine you want to use typing.Literal in a Pizza class:

from typing import Literal


class Pizza:
    def __init__(self, base: Literal["deep-pan", "thin"]) -> None:
        self.base = base

Literal was only introduced in Python 3.8. For this code to work on Python 3.7 or earlier, you can conditionally avoid using Literal, and instead fall back to a simpler type. Since the literal valuse are all strings, you can use str on ye olde Pythons:

import sys

if sys.version_info >= (3, 8):
    from typing import Literal

    PizzaBaseType = Literal["deep-pan", "thin"]
else:
    PizzaBaseType = str


class Pizza:
    def __init__(self, base: PizzaBaseType) -> None:
        self.base = base

This passes type checking on old and new Python versions alike. Mypy parses the if sys.version_info branch and only interprets the code matching the Python version it targets. You can tell Mypy which Python version to target with the --python-version flag:

$ mypy --python-version 3.7 example.py
Success: no issues found in 1 source file
$ mypy --python-version 3.8 example.py
Success: no issues found in 1 source file

This is purely internal, so it doesn’t require you to have that version of Python installed. (Without --python-version, Mypy assumes you target the version it is installed with.)

Literal has a natural fallback type on older versions: the type of the allowed literal values. This still provides some level of type checking safety. But for more complicated types, you might be forced to use Any as the fallback type, which disables type checking—not great. Luckily, we have an alternative...

Using Backported Types from typing-extensions

The typing-extensions package contains backported and experimental typing features. Mypy interprets imports from the typing_extensions module as the equivalent typing types, allowing you to use them on older Python versions.

You can solely rely on typing_extensions, and it will work on all Python versions:

from typing_extensions import Literal


class Pizza:
    def __init__(self, base: Literal["deep-pan", "thin"]) -> None:
        self.base = base

Or, to avoid the unnecessary dependency on typing-extensions in newer Python versions:

import sys

if sys.version_info >= (3, 8):
    from typing import Literal
else:
    from typing_extensions import Literal


class Pizza:
    def __init__(self, base: Literal["deep-pan", "thin"]) -> None:
        self.base = base

If you’re developing a project, you likely have typing-extensions installed already in your virtual environment, as Mypy depends on it. But if you’re developing a library, you’ll need to declare your dependency on typing-extensions for older Python versions. For a setuptools package you can declare this in setup.cfg like so:

[options]
...
install_requires =
    ...
    typing-extensions ; python_version < "3.8"

You could try to avoid this dependency outside of type checking. It’s possible to do so by gating the import with a check on TYPE_CHECKING:

import sys
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    if sys.version_info >= (3, 8):
        from typing import Literal
    else:
        from typing_extensions import Literal

    PizzaBaseType = Literal["deep-pan", "thin"]
else:
    PizzaBaseType = str


class Pizza:
    def __init__(self, base: PizzaBaseType) -> None:
        self.base = base

…but that is pretty complicated.

I think it’s best to declare the typing-extensions dependency, or use the fallback pattern. typing-extensions does not add much overhead.

Fin

May your types be as compatible as necessary,

—Adam


Learn how to make your tests run quickly in my book Speed Up Your Django Tests.


Subscribe via RSS, Twitter, Mastodon, or email:

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

Related posts:

Tags: ,