Disjoint Bases: Telling Checkers Which Classes Can’t Mix (PEP 800)

Authors

K19G

grok-bot

Updated

October 31, 2026

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

Three columns. Left: CPython runtime, where int, str, list, dict and any class with non-empty __slots__ have their own instance layout, so combining two of them raises TypeError: multiple bases have instance lay-out conflict. Middle: the @typing.disjoint_base decorator, which only sets __disjoint_base__ = True and is read by type checkers. Right: type checkers, which reject class definitions that combine two unrelated disjoint bases and may treat isinstance checks across them as unreachable.

Runtime layout conflicts, the decorator, and what checkers do with it.
  • 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 raises TypeError.
  • The decorator. @disjoint_base works like @final at runtime: it sets cls.__disjoint_base__ = True and returns the class. It doesn’t check anything itself. It exists so typeshed stubs can mark C classes like int, 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 when x: 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+list and dict+list. int+object is fine, because object is the parent of every disjoint base.
  • Line 38 (Both). Vector and Matrix both 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). TagA and TagB have 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 v to Never inside the branch, so assert_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, so assert_never fails. It enforces the class-definition rule but doesn’t (yet) use it for narrowing.
  • Line 32. Plain isn’t a disjoint base, so isinstance(v, Plain) stays reachable in every checker. A class VP(Vector, Plain) is legal.
  • Lines 59–60 read the marker with getattr. Direct access to Vector.__disjoint_base__ type-checks as an unknown attribute in pyright and ty (tried during this run), because the stubs don’t declare it.

Pitfalls

  1. Treating the decorator as enforcement. TagAB shows that @disjoint_base on 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.
  2. Trusting checker messages about the cause. ty says TagAB “will raise TypeError at runtime” and that TagA “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.
  3. Expecting identical narrowing. Code that relies on assert_never after an impossible isinstance passes 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 write assert_never there. Write a plain raise AssertionError, or simply drop the branch.
  4. Forgetting object. object counts as a disjoint base, but it’s an ancestor of every class, so it never conflicts. Only unrelated disjoint bases clash. A subclass of Vector can be combined with Vector.
  5. 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

  1. Remove __slots__ from Matrix (keep the decorator). Predict the runtime output for Both and each checker’s output, then run them.
  2. Replace the isinstance(v, Matrix) branch with if isinstance(v, int):. Do the checkers treat it as unreachable without any decorator? Why might typeshed’s markings on int matter here?
  3. Convert Vector to @dataclass(slots=True) without the decorator and confirm each checker still treats it as a disjoint base.
  4. Write a small runtime check can_combine(a: type, b: type) -> bool that tries type("_", (a, b), {}). Compare its answers with getattr(cls, "__disjoint_base__", False) for Vector, TagA, int and Plain.

Sources