Skip to content
← All articles

Disjoint bases (PEP 800): the types that cannot exist

Why mypy calls a branch unreachable and pyright does not, why isinstance against a Protocol never narrows to Never, and how @typing.disjoint_base finally makes the rule explicit.

Ask a type checker whether a value can be both a str and an int at the same time. Until very recently, the honest answer from the type system was “as far as I know, yes” — because nothing in the annotations said otherwise. A class could in principle inherit from both, so str & int was an inhabited type, and code guarded by isinstance(x, str) and isinstance(x, int) was reachable.

Python’s runtime disagrees, loudly:

TypeError: multiple bases have instance lay-out conflict

PEP 800, targeting 3.15 and available today through typing_extensions, gives the type system a way to say that.

The mechanism

@typing.disjoint_base marks a class whose memory layout prevents co-inheritance with another such class. Two disjoint bases cannot both appear in an MRO, so their intersection is Never — uninhabited — and a checker may treat the corresponding branch as unreachable.

What is a disjoint base:

  • C-implemented builtins. int, str, bytes, list, dict, tuple, set — each has its own tp_basicsize and instance layout.
  • object itself, in the relevant sense.
  • Classes with a non-empty __slots__. Slots reserve storage in the instance layout, and two slot layouts cannot be merged.
  • @dataclass(slots=True) classes, which are the previous case by construction.

What is not:

  • A plain Python class with no __slots__. Two of those combine fine.
  • A class with __slots__ = (). Empty slots reserve nothing, which is exactly why the idiom exists for mixins.
  • Protocols. Protocols cannot be marked disjoint at all.
💡That last bullet looks like an oversight. Why would the PEP forbid marking a Protocol as a disjoint base? click to reveal

Because it would make the protocol nominal, and quietly.

A disjoint-base marking is a statement about instance layout — a runtime, implementation-level fact. A protocol is a statement about shape — a structural, interface-level fact. A class satisfies a protocol without any relationship to it in the MRO, so there is no layout to be disjoint from.

The consequence is the one you actually notice in a diagnostic log: isinstance narrowing against a Protocol never produces Never. Given x: SomeProtocol and a check isinstance(x, AnotherProtocol), the negative branch always stays reachable, because there could always be a class satisfying both — you can always write one. That is not a checker being timid; it is structurally correct.

If you want an exhaustive-narrowing story, you need nominal types with disjoint layouts, or a discriminated union with Literal tags. A pair of Protocols will never give you exhaustiveness.

Why this explains a whole class of diagnostics

“Why does mypy call this unreachable and pyright not?” (or the reverse) is one of the most common cross-checker complaints, and a large share of the cases come down to each checker’s own hard-coded, undocumented notion of which types are mutually exclusive. mypy has long had special knowledge about builtins; pyright has different special knowledge; neither had a way for your classes to participate.

Consider:

def handle(x: int | str) -> str:
    if isinstance(x, int):
        return str(x)
    if isinstance(x, int):        # can this branch run?
        return "impossible"
    return x

Everyone agrees on that one, because int is special-cased everywhere. Now:

@dataclass(slots=True)
class A: ...

@dataclass(slots=True)
class B: ...

def f(x: A) -> str:
    if isinstance(x, B):          # can this branch run?
        return "both"
    return "just A"

Before PEP 800 this was genuinely ambiguous. Nothing in the type system said slots make A and B incompatible, so a checker either hard-coded the rule or stayed silent. After PEP 800, @dataclass(slots=True) implies a disjoint base and the branch is Never.

💡--warn-unreachable is not part of mypy's --strict. Given everything above, is that omission a mistake? click to reveal

It is a defensible choice, and PEP 800 is part of the reason.

--warn-unreachable fires on code the checker believes cannot execute. Its false-positive rate depends entirely on how precisely the checker models disjointness — and until 3.15 that model was a pile of special cases. A false “unreachable” is an unusually annoying diagnostic: it is telling you to delete working code, and the fix is often a # type: ignore that then rots.

There is a second, sharper source of false positives that has nothing to do with PEP 800: --warn-unreachable will flag a defensive else: raise TypeError(...) that exists precisely because the caller might not be typed. The branch is unreachable according to the annotations, and reachable according to reality, which is the whole reason the defence is there.

So: enable it, but enable it deliberately and expect to argue with it a few times — which is exactly the syllabus’s Silver tier rather than Bronze. Note also, as a factual matter, that --strict includes exactly thirteen flags and --warn-unreachable is not among them, despite appearing on several widely-copied “strict flags” blog posts. Neither is --warn-unused-configs.

Using it today

from typing_extensions import disjoint_base

@disjoint_base
class Handle:
    __slots__ = ("fd",)
    fd: int

On 3.15 this comes from typing. Before that, typing_extensions provides it and checkers that implement PEP 800 will honour it.

You mostly will not write it by hand. The value is in the rules it makes explicit — because those rules were always in force in CPython’s object model, and until now the type system simply could not see them.

💡You have a mixin you want to combine freely with slotted classes. What do you write, and why does it work? click to reveal

__slots__ = ().

An empty slots tuple declares “this class adds no instance storage”. It suppresses the automatic __dict__ that a plain class would contribute — which is the actual reason mixins want it, since one non-slotted class anywhere in the MRO reintroduces __dict__ and silently undoes the memory saving of every slotted class below it. And because it reserves nothing, it creates no layout conflict and is not a disjoint base.

This is the standard idiom for collections.abc-style mixins and for anything meant to be combined. Worth remembering alongside the honest figure for what slots actually buy: roughly 30% instance memory on modern CPython, not the 5x-to-10x that older material claims, and no attribute access speed-up — measured at about 3.9 ns with slots versus 3.8 ns without. You use slots for memory and for typo-safety, not for speed.