skip to content

How do you use a typing feature your project's minimum Python lacks?

level: middleimportance: should knowfreq 40%

answer

  1. The type system outruns the runtimes
  2. Mirror new names onto old interpreters
  3. A branch the checker resolves statically
  4. Names can be shimmed, syntax cannot
  5. sys.version_info against a tuple literal

basics

~20 s

Import the name from the typing back-port distribution, which mirrors newer typing names onto older runtimes, or branch on sys.version_info between the standard-library name and a fallback. If the name is used only in annotations, a TYPE_CHECKING-guarded import needs no dependency at all.

solid answer

~50 s

There are three routes and they trade off differently. The simplest is to import the name unconditionally from the typing back-port distribution, which re-exports the standard-library object when the running version has it — one import path, no dead branch, but a real install dependency. The second is a `sys.version_info >= (3, N)` branch that takes the standard-library name on new runtimes and a fallback otherwise; checkers understand comparisons of `sys.version_info` against a tuple literal and analyse only the branch matching the version they target. The third, when the name appears only inside annotations, is a `TYPE_CHECKING`-guarded import plus a quoted or lazily-evaluated hint, which costs nothing at runtime. Declare the floor with `requires-python` in `pyproject.toml`, and gate the back-port with a `python_version` environment marker if you want it installed only where it is needed.

code

python · 13 lines
python
import sys

if sys.version_info >= (3, 11):
    from typing import Never
else:
    from typing import NoReturn as Never


def reject(reason: str) -> Never:
    raise ValueError(reason)


print(Never)

go deeper

for a junior

Know that a name added in a later Python cannot simply be imported on an older one, and that projects either depend on a back-port distribution or branch on sys.version_info to cope.

for a middle

Explain both routes and their costs, and show the branch form a checker can resolve — sys.version_info compared against a tuple literal at module level. Say why an annotation-only name may need no dependency at all.

for a senior

Decide per project: floor declared in pyproject.toml, one convention rather than three, dependency gated by an environment marker where it helps, and every supported version in the CI matrix so the fallback branch is actually exercised.

for a principal

Own the support policy itself. How long the oldest runtime stays supported, what the typing vocabulary costs in dependencies and branches, and when raising the floor is cheaper than carrying shims are your calls, not the individual module author's.

### The problem The type system moves faster than the runtimes people are still on. `typing.Self` and `typing.Never` arrived in 3.11, `typing.override` in 3.12, PEP 695 type-parameter syntax in 3.12. A library whose floor is older than those cannot simply import the name: the module would fail to import on the very runtimes it promises to support. Version skew is the gap between the typing vocabulary you want to write and the vocabulary your oldest supported interpreter can parse and bind. ### Route one: the back-port distribution The typing ecosystem ships a back-port distribution whose whole job is to make new `typing` names importable on older runtimes; where the running interpreter already has the name, it re-exports the standard-library object rather than a copy. Import from it unconditionally and the source has one import path, no branch, and nothing to keep in sync. Checkers understand it natively, because it is part of the typing specification's tooling story. The cost is an install-time dependency. Declare it in `pyproject.toml` like any other requirement, and gate it with a `python_version` environment marker if you want it installed only on runtimes that lack the name. Whether it needs to be a hard runtime dependency at all depends on where the name is used, which is the distinction below. ### Route two: a sys.version_info branch ```python import sys if sys.version_info >= (3, 11): from typing import Never else: from typing import NoReturn as Never ``` `sys.version_info` is a named tuple that compares element-wise against a tuple literal, so this is an ordinary runtime condition. What makes it useful for typing is that checkers evaluate the comparison statically: they are told which version to analyse for, resolve the condition themselves, and analyse only the branch that would be taken. That gives you one name with two provenances and correct analysis on every supported version. Checkers recognise a narrow set of forms — a comparison of `sys.version_info`, or of `sys.version_info[0]`, against a tuple or integer literal. Hide the version in a helper variable or a function call and the branch stops being statically resolvable, and the checker treats both sides as live or gives up. Keep the comparison literal and at module level. ### Route three: the checker-only import If the new name appears only inside annotations, neither dependency is needed. Guard the import with `TYPE_CHECKING` and keep the hint unevaluated. On 3.14 annotations are lazily evaluated by default (PEP 649), and on earlier runtimes the module carries `from __future__ import annotations` or quotes the hint. The checker gets the modern vocabulary and the shipped package gains nothing at all — no dependency, no branch. This route stops working the moment the name is evaluated at runtime: a decorator you actually apply, a runtime-checkable protocol used with `isinstance()`, a base class, an argument to a real call. Those need a genuine import, which means route one or two and a real dependency. ### What cannot be shimmed A back-port can supply a name; it cannot supply syntax. PEP 695 generics, the `X | Y` union operator outside a string on runtimes before 3.10, and any new grammar are parse-time features — an old interpreter raises `SyntaxError` before any conditional import runs, so no branch can save you. For syntax the choices are honest ones: raise the floor, write the older equivalent, or, for unions specifically, keep them inside annotations that are never evaluated. ### Declaring the floor All of this is downstream of one decision: what minimum version does the project support? Record it as `requires-python` in `pyproject.toml` so installers refuse the package on older interpreters instead of failing at import, run the checker against that floor rather than against whatever the developer has locally, and put every supported version in the CI matrix. A shim branch that is only ever taken on the oldest supported runtime is untested unless that runtime is in the matrix, and untested code that only the oldest users execute is the worst kind of untested. ### Choosing between the routes Default to the back-port when the name is needed at runtime, because one import path with no dead branch is cheaper to maintain than two. Use a version branch when you want to shed the dependency on newer runtimes or when the fallback is a local alias with no package behind it. Use the guarded import when the name lives only in annotations. And write down which route the project uses, because a codebase that mixes all three at random is one where nobody can tell which names are safe to call.

  • When does the back-port have to be a real runtime dependency rather than a checker-only import?
    Whenever the name is evaluated while the program runs: a decorator you apply, a base class, a protocol used with `isinstance()`, an argument to an actual call. If the name appears only inside annotations, and those annotations are lazily evaluated on 3.14 or quoted on older runtimes, it can stay behind the `TYPE_CHECKING` guard and out of the install requirements entirely.
  • Why prefer an unconditional back-port import over a sys.version_info branch?
    One import path instead of two, and no branch to rot. The back-port re-exports the standard-library object when the running interpreter already has the name, so behaviour matches on new runtimes. A branch is worth its cost when you want to drop the dependency on newer versions, or when the fallback is a local alias with no package behind it.
  • Can a shim back-port PEP 695 generic syntax to an older interpreter?
    No. That is grammar, and an older interpreter raises `SyntaxError` while parsing the file, before any conditional import can execute. Names can be shimmed; syntax cannot. The options are to raise the floor, to write the pre-3.12 equivalent with explicit type variables, or to keep the construct inside an annotation that is never evaluated.

saying these in an interview costs you the question

  • Thinks a back-port package can add new syntax
  • Hides the version test in a variable the checker cannot resolve
  • Imports the new name unguarded and breaks the oldest runtime
  • Assumes the checker analyses both sides of a version branch
  • Ships a back-port-only name behind a checker-only import

context