Disjoint Bases: Telling Checkers Which Classes Can’t Mix (PEP 800)
Disjoint Bases: Telling Checkers Which Classes Can’t Mix (PEP 800)
Thesis
Some classes can never share a subclass. class X(int, str) fails at class-creation time with TypeError: multiple bases have instance lay-out conflict, because CPython lays out an int and a str differently in memory and one object can’t be both. Type checkers didn’t know this. So a branch like if isinstance(n, str): with n: int looked reachable to some checkers and unreachable to others, each using its own heuristic.
PEP 800 (Final, Python 3.15) writes the rule into the type system. A class is a disjoint base if it is decorated with the new @typing.disjoint_base, has a non-empty __slots__, or is object. Two disjoint bases that aren’t parent and child can’t both appear in one class’s MRO. Checkers can then reject the impossible class and treat isinstance checks across such classes as unreachable. This chapter runs one file under CPython 3.15.0 and three checkers. All three agree on the class definitions, and they give three different answers on narrowing.
Mental model
- The runtime fact. CPython calls these “solid bases”, a concept dating back to 2001. Each instance has one C-level layout. Two bases that each add their own fields, such as a C struct or
__slots__, can’t be merged, so class creation raisesTypeError. - The decorator.
@disjoint_baseworks like@finalat runtime: it setscls.__disjoint_base__ = Trueand returns the class. It doesn’t check anything itself. It exists so typeshed stubs can mark C classes likeint, and so authors can promise the property for their own classes. - Automatic disjoint bases. A non-empty
__slots__, including@dataclass(slots=True), makes a class a disjoint base without the decorator. - What checkers do. They reject a class whose bases include two unrelated disjoint bases. Because no such subclass can exist, they may also treat
isinstance(x, B)as unreachable whenx: A.
Worked example
disjoint.py, run with CPython 3.15.0:
"""PEP 800 (Python 3.15): telling type checkers which classes can never share a subclass."""
from typing import assert_never, disjoint_base
# 1) The runtime fact PEP 800 models: some C-level classes cannot be combined.
for bases in [(int, str), (int, list), (dict, list), (int, object)]:
try:
type("Mix", bases, {})
print(f"class Mix{tuple(b.__name__ for b in bases)}: ok")
except TypeError as e:
print(f"class Mix{tuple(b.__name__ for b in bases)}: TypeError: {e}")
# 2) Pure-Python classes can declare the same property with the decorator.
@disjoint_base
class Vector:
__slots__ = ("x", "y")
@disjoint_base
class Matrix:
__slots__ = ("rows",)
class Plain: # not a disjoint base
pass
def describe(v: Vector) -> str:
if isinstance(v, Matrix): # a checker that knows PEP 800 narrows v to Never here
assert_never(v)
if isinstance(v, Plain): # possible: class VP(Vector, Plain) is allowed
return "vector-and-plain"
return "vector"
try:
class Both(Vector, Matrix): # a PEP 800 checker reports this class definition
pass
except TypeError as e: # ...and here the runtime agrees, because of the slots
print("class Both(Vector, Matrix): TypeError:", e)
@disjoint_base
class TagA: # the decorator alone changes nothing at runtime
pass
@disjoint_base
class TagB:
pass
class TagAB(TagA, TagB): # runtime allows it; a PEP 800 checker reports it
pass
if __name__ == "__main__":
print("marker:", getattr(Vector, "__disjoint_base__", None),
getattr(Plain, "__disjoint_base__", None))
print("TagAB created at runtime:", [c.__name__ for c in TagAB.__mro__])Runtime output
$ python3.15 disjoint.py
class Mix('int', 'str'): TypeError: multiple bases have instance lay-out conflict
class Mix('int', 'list'): TypeError: multiple bases have instance lay-out conflict
class Mix('dict', 'list'): TypeError: multiple bases have instance lay-out conflict
class Mix('int', 'object'): ok
class Both(Vector, Matrix): TypeError: multiple bases have instance lay-out conflict
marker: True None
TagAB created at runtime: ['TagAB', 'TagA', 'TagB', 'object']
pyright 1.1.414
$ pyright --pythonversion 3.15 disjoint.py
disjoint.py
disjoint.py:31:22 - error: Argument of type "<subclass of Vector and Matrix>" cannot be assigned to parameter "arg" of type "Never" in function "assert_never"
Type "<subclass of Vector and Matrix>" is not assignable to type "Never" (reportArgumentType)
disjoint.py:38:11 - error: Base classes have incompatible disjoint bases: "Vector", "Matrix" (reportGeneralTypeIssues)
disjoint.py:54:7 - error: Base classes have incompatible disjoint bases: "TagA", "TagB" (reportGeneralTypeIssues)
3 errors, 0 warnings, 0 informations
ty 0.0.95
$ ty check --python-version 3.15 disjoint.py
error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases
--> disjoint.py:38:11
|
38 | class Both(Vector, Matrix): # a PEP 800 checker reports this class definition
| ^^^^^^^^^^^^^^^^^^^^ Bases `Vector` and `Matrix` cannot be combined in multiple inheritance
info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts
--> disjoint.py:38:16
|
38 | class Both(Vector, Matrix): # a PEP 800 checker reports this class definition
| ------ ------ `Matrix` instances have a distinct memory layout because of the way `Matrix` is implemented in a C extension
| |
| `Vector` instances have a distinct memory layout because of the way `Vector` is implemented in a C extension
error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases
--> disjoint.py:54:7
|
54 | class TagAB(TagA, TagB): # runtime allows it; a PEP 800 checker reports it
| ^^^^^^^^^^^^^^^^^ Bases `TagA` and `TagB` cannot be combined in multiple inheritance
info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts
--> disjoint.py:54:13
|
54 | class TagAB(TagA, TagB): # runtime allows it; a PEP 800 checker reports it
| ---- ---- `TagB` instances have a distinct memory layout because of the way `TagB` is implemented in a C extension
| |
| `TagA` instances have a distinct memory layout because of the way `TagA` is implemented in a C extension
Found 2 diagnostics
mypy 2.4.1
$ mypy --python-version 3.15 --warn-unreachable disjoint.py
disjoint.py:30: error: Subclass of "Vector" and "Matrix" cannot exist: have distinct disjoint bases [unreachable]
disjoint.py:38: error: Class "Both" has incompatible disjoint bases [misc]
disjoint.py:54: error: Class "TagAB" has incompatible disjoint bases [misc]
Found 3 errors in 1 file (checked 1 source file)
Reading the output
- Lines 6–11. The runtime refuses
int+str,int+listanddict+list.int+objectis fine, becauseobjectis the parent of every disjoint base. - Line 38 (
Both).VectorandMatrixboth have non-empty__slots__, so the runtime raises and all three checkers report the class. Here the decorator is redundant, since the slots alone make each class a disjoint base, but it records the intent. - Line 54 (
TagAB).TagAandTagBhave no slots. The decorator is only a promise, so the runtime builds the class without complaint (TagAB’s MRO is printed). All three checkers still reject it. That is the point of the decorator: it states a constraint the checker enforces even where the runtime doesn’t. - Lines 30–31 (narrowing). This is where the checkers differ:
- ty narrows
vtoNeverinside the branch, soassert_never(v)passes with no diagnostic. - mypy (with
--warn-unreachable) reports the branch as unreachable. - pyright 1.1.414 still infers
<subclass of Vector and Matrix>there, soassert_neverfails. It enforces the class-definition rule but doesn’t (yet) use it for narrowing.
- ty narrows
- Line 32.
Plainisn’t a disjoint base, soisinstance(v, Plain)stays reachable in every checker. Aclass VP(Vector, Plain)is legal. - Lines 59–60 read the marker with
getattr. Direct access toVector.__disjoint_base__type-checks as an unknown attribute in pyright and ty (tried during this run), because the stubs don’t declare it.
Pitfalls
- Treating the decorator as enforcement.
TagABshows that@disjoint_baseon a plain class changes nothing at runtime. If you need the runtime to refuse mixing, give the class a non-empty__slots__(or a C layout). The decorator then just documents a fact the runtime already enforces. - Trusting checker messages about the cause. ty says
TagAB“will raiseTypeErrorat runtime” and thatTagA“is implemented in a C extension”. Both statements are false for this file: the class was created fine. The diagnostic is correct under PEP 800, but its explanation assumes the decorator matches the runtime. - Expecting identical narrowing. Code that relies on
assert_neverafter an impossibleisinstancepasses in ty, is flagged as dead code in mypy (only with--warn-unreachable), and fails in pyright 1.1.414. If you support several checkers, don’t writeassert_neverthere. Write a plainraise AssertionError, or simply drop the branch. - Forgetting
object.objectcounts as a disjoint base, but it’s an ancestor of every class, so it never conflicts. Only unrelated disjoint bases clash. A subclass ofVectorcan be combined withVector. - Adding
__slots__changes a public class. Since a non-empty__slots__makes a class a disjoint base, adding slots to a library class can break users’ multiple inheritance, both statically (PEP 800) and at runtime. Treat it as an API change.
Exercises
- Remove
__slots__fromMatrix(keep the decorator). Predict the runtime output forBothand each checker’s output, then run them. - Replace the
isinstance(v, Matrix)branch withif isinstance(v, int):. Do the checkers treat it as unreachable without any decorator? Why might typeshed’s markings onintmatter here? - Convert
Vectorto@dataclass(slots=True)without the decorator and confirm each checker still treats it as a disjoint base. - Write a small runtime check
can_combine(a: type, b: type) -> boolthat triestype("_", (a, b), {}). Compare its answers withgetattr(cls, "__disjoint_base__", False)forVector,TagA,intandPlain.
Sources
- PEP 800, “Disjoint bases in the type system” (Final, Python 3.15): https://peps.python.org/pep-0800/
typing.disjoint_basedocumentation: https://docs.python.org/3.15/library/typing.html#typing.disjoint_base- What’s New in Python 3.15, typing section: https://docs.python.org/3.15/whatsnew/3.15.html
- Typing spec (canonical, maintained): https://typing.python.org/en/latest/spec/
- pyright: https://github.com/microsoft/pyright; ty: https://docs.astral.sh/ty/; mypy: https://mypy.readthedocs.io/