mypy 2.4.0 no longer reports an error for a PEP 695 type parameter whose constraint tuple is empty (class C[T: ()]). mypy 2.1.0, 2.2.0 and 2.3.0 all report Type variable must have at least two constrained types [misc] on that line. The one-element case T: (str,) and the legacy TypeVar("T", str) are still reported in 2.4.0, so only the empty-tuple path regressed.
To Reproduce
classClassK[T: ()]: # expected: error: Type variable must have at least two constrained types [misc]
...
classClassL[T: (str,)]: # still reported in 2.4.0
...
$ mypy --python-version 3.12 repro.py
Expected Behavior
An error on the ClassK line, as in mypy ≤ 2.3.0. CPython itself rejects the declaration at runtime (TypeError: A single constraint is not allowed / empty constraints), and pyright 1.1.414, pyrefly 1.3.2, ty 0.0.86 and zuban 0.10.0 all report it.
Actual Behavior
mypy 2.4.0:
repro.py:4: error: Type variable must have at least two constrained types [misc]
Found 1 error in 1 file (checked 1 source file)
(only ClassL; ClassK on line 1 is silently accepted)
mypy 2.3.0:
repro.py:1: error: Type variable must have at least two constrained types [misc]
repro.py:4: error: Type variable must have at least two constrained types [misc]
Found 2 errors in 1 file (checked 1 source file)
This also flips the typing conformance test generics_syntax_declarations.py (line 60, class ClassK[T: ()]: # E) from automated Pass (mypy 2.1.0, as in the published results) to Fail with 2.4.0.
Your Environment
Mypy version used: 2.4.0 (compiled: yes); compared against 2.1.0 / 2.2.0 / 2.3.0 in separate venvs
Mypy command-line flags: --python-version 3.12 (also reproduces with no flags)
Mypy configuration options from mypy.ini (and other config files): none
I looked into this. Root cause: the native parser (default since 2.4.0, #21823) can’t distinguish T: () from a plain T. Both arrive in read_type_params as has_bound=False, n_values=0:
class A[T: ()] -> has_bound=False n_values=0
class B[T] -> has_bound=False n_values=0
class C[T: int] -> has_bound=True n_values=0
class D[T: (int,str)] -> has_bound=False n_values=2
--no-native-parser reports both lines, so only the native path regressed. The old parser catches it with len(p.bound.elts) < 2 in fastparse.py, but the native check in check_type_param_values is len(values) == 1, and the empty tuple appears to be lost before mypy sees it (in ast-serialize). I’d like to work on this. Should the fix be in ast-serialize (e.g. flagging that a constraint tuple was present) with a mypy-side bump?
Activity
CheerathAniketh commented on Oct 11, 2026
I looked into this. Root cause: the native parser (default since 2.4.0, #21823) can’t distinguish T: () from a plain T. Both arrive in read_type_params as has_bound=False, n_values=0:
class A[T: ()] -> has_bound=False n_values=0
class B[T] -> has_bound=False n_values=0
class C[T: int] -> has_bound=True n_values=0
class D[T: (int,str)] -> has_bound=False n_values=2
--no-native-parser reports both lines, so only the native path regressed. The old parser catches it with len(p.bound.elts) < 2 in fastparse.py, but the native check in check_type_param_values is len(values) == 1, and the empty tuple appears to be lost before mypy sees it (in ast-serialize). I’d like to work on this. Should the fix be in ast-serialize (e.g. flagging that a constraint tuple was present) with a mypy-side bump?