This article is optional, because it explains a mypy error that most code never triggers and a Python 3.15 decorator meant for library authors. You can skip it on a first pass.
Here is a check that can never succeed:
def describe(value: int) -> str:
if isinstance(value, str):
return "text"
return "number"
With --warn-unreachable, which this site's grader uses, mypy reports:
error: Subclass of "int" and "str" cannot exist: have distinct disjoint bases [unreachable]
error: Statement is unreachable [unreachable]
Try the same check with two classes of your own, and mypy reports nothing:
class Reader:
pass
class Writer:
pass
def describe(value: Reader) -> str:
if isinstance(value, Writer):
return "reader and writer"
return "reader"
Some class could inherit from both, such as class Duplex(Reader, Writer),
and an instance of it would pass the check. So this branch can run. For
int and str, no such class can exist.
Some classes have a layout of their own
Python refuses to build a class that inherits from both int and str:
try:
class Both(int, str):
pass
except TypeError as error:
print(error) # multiple bases have instance lay-out conflict
An int and a str each store their data inside the object, in a layout
of their own, and one object cannot have two layouts. A class like this is
a disjoint base. A class can inherit from two disjoint bases only when
one of them is a subclass of the other. mypy knows which built-in classes
are disjoint bases, so it knows the isinstance check at the top of this
article is never true.
Non-empty slots make your own class a disjoint base
A class with non-empty __slots__ stores its attributes at fixed places
inside each instance, so it has a layout of its own too. That includes a
dataclass with slots=True and at least one field:
from dataclasses import dataclass
@dataclass(slots=True)
class HasX:
x: int
@dataclass(slots=True)
class HasY:
y: int
try:
class Point(HasX, HasY):
pass
except TypeError as error:
print(error) # multiple bases have instance lay-out conflict
mypy reports Class "Point" has incompatible disjoint bases without running
the code. A class with empty slots, __slots__ = (), adds no storage, so it
combines with other classes without a layout conflict.
Check your understanding
Does @dataclass(slots=True) always make a class a disjoint base?
Show answer
Hide answer
No. It does only when the dataclass adds at least one field of its own,
because only then does it add a slot. A dataclass with no fields gets
__slots__ = (), which adds no storage, so it combines with other classes
like an empty-slots mixin.
Mark C classes with @disjoint_base
mypy sees __slots__ in Python code, but it cannot see the layout of a
class written in C, such as one from an extension module. For those, the
class's stub marks it with @disjoint_base, from typing in Python 3.15 or
from typing_extensions in earlier versions. The stubs that ship with mypy
already mark int, str and many other built-in classes this way.
The decorator only sets __disjoint_base__ = True on the class. Python
itself ignores that attribute, so this code runs, and only mypy objects:
from typing import disjoint_base
@disjoint_base
class FileHandle:
pass
@disjoint_base
class SocketHandle:
pass
class Both(FileHandle, SocketHandle):
pass
mypy reports Class "Both" has incompatible disjoint bases. In application
code you rarely need the decorator.
Common mistakes
Deleting a check that mypy calls unreachable. The annotation
value: int is a promise from the caller. If the value comes from outside
your program, such as parsed JSON, it may be a string after all. Type such
input as object and narrow it with isinstance, so mypy and the runtime
agree. Delete the check only when the value comes from code that mypy
checks.
Putting @disjoint_base on a protocol. A protocol describes methods,
not a layout, so mypy rejects it with
@disjoint_base cannot be used with protocol class.
Going further
PEP 800 defines disjoint bases. mypy 2.4.0, the version this site runs, reports every error shown here.