Python Type Hints - How to Split Types by Python Version

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.
Learn how to make your tests run quickly in my book Speed Up Your Django Tests.
One summary email a week, no spam, I pinky promise.
Related posts: