Skip to content

PEP 800: Solid bases in the type system #4505

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ peps/pep-0793.rst @encukou
peps/pep-0794.rst @brettcannon
peps/pep-0798.rst @JelleZijlstra
# ...
peps/pep-0800.rst @JelleZijlstra
peps/pep-0801.rst @warsaw
# ...
peps/pep-2026.rst @hugovk
Expand Down
349 changes: 349 additions & 0 deletions peps/pep-0800.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
PEP: 800
Title: Solid bases in the type system
Author: Jelle Zijlstra <[email protected]>
Discussions-To: Pending
Status: Draft
Type: Standards Track
Topic: Typing
Created: 21-Jul-2025
Python-Version: 3.15
Post-History: `18-Jul-2025 <https://discuss.python.org/t/solid-bases-for-detecting-incompatible-base-classes/99280>`__


Abstract
========

To analyze Python programs precisely, type checkers need to know when two classes can and cannot have a common child class.
However, the information necessary to determine this is not currently part of the type system. This PEP adds a new
decorator, ``@typing.solid_base``, that indicates that a class is a "solid base". Two classes that have distinct, unrelated
solid bases cannot have a common child class.

Motivation
==========

In type checking Python, an important concept is that of reachability. Python type checkers generally
detect when a branch of code can never be reached, and and they warn users about such code. This is useful
because unreachable code unnecessarily complicates the program, and its presence can be an indication of a bug.

For example, in this program::

def f(x: bool) -> None:
if isinstance(x, str):
print("It's both!")

both pyright and mypy (with ``--warn-unreachable``), two popular type checkers, will warn that the body of the
``if`` block is unreachable, because if ``x`` is a ``bool``, it cannot also be a ``str``.

Reachability is complicated in Python by the presence of multiple inheritance. If instead of ``bool`` and ``str``,
we use two user-defined classes, mypy and pyright do not show any warnings::

class A: pass
class B: pass

def f(x: A):
if isinstance(x, B):
print("It's both!")

This is correct, because there a class that inherits from both ``A`` and ``B`` could exist.

We see a divergence between the type checkers in another case, where we use ``int`` and ``str``::

def f(x: int):
if isinstance(x, str):
print("It's both!")

On this code, pyright shows no errors but mypy will claim that the branch is unreachable. Mypy is technically correct
here: CPython does not allow a class to inherit from both ``int`` and ``str``, so the branch is unreachable.
However, the information necessary to determine that these base classes are incompatible is not currently available in
the type system. Mypy in fact uses a heuristic based on the presence of incompatible methods; this heuristic works
reasonably well in practice, especially for built-in types, but it is
`incorrect in general <https://github.com/python/mypy/issues/19377>`__.

The experimental ``ty`` type checker uses a third approach that aligns more closely with the runtime behavior of Python:
it recognizes certain classes as "solid bases" that restrict multiple inheritance. Broadly speaking, every class must
inherit from a unique solid base, and if there is no unique solid base, the class cannot exist; we'll provide a more
precise definition below. However, ty's approach relies on hardcoded knowledge of particular built-in types.

This PEP proposes an extension to the type system that makes it possible to express when multiple inheritance is not
allowed: an ``@solid_base`` decorator that marks classes as "solid bases".
This gives type checkers a more precise understanding of reachability, and helps in several concrete areas.

Invalid class definitions
-------------------------

The following class definition raises an error at runtime, because ``int`` and ``str`` are distinct solid bases::

class C(int, str): pass

Without a knowledge of solid bases, type checkers are not currently able to detect the reason why this class
definition is invalid, though they may detect that if this class were to exist, some of its methods would be incompatible.

This is not a particularly compelling problem by itself, as the error would usually be caught the first time the code
is imported, but it is mentioned here for completeness.

Reachability
------------

We already mentioned the reachability of code using ``isinstance()``. Similar issues arise with other type
narrowing constructs such as ``match`` statements: correct inference of reachability requires an understanding of
solid bases.

::

class A: pass
class B: pass

def f(x: A):
match x:
case B(): # reachable
print("It's both!")

def g(x: int):
match x:
case str(): # unreachable
print("It's both!")

Overloads
---------

Functions decorated with ``@overload`` may be unsafe if the parameter types of some overloads overlap, but the return types
do not. For example, the following set of overloads could be exploited to
`achieve unsound behavior <https://github.com/JelleZijlstra/unsoundness/blob/04d16e5ea1a6492d82e8131f72894c9dcad1a55c/examples/overload/undetected_overlap.py>`__::

from typing import overload

class A: pass
class B: pass

@overload
def f(x: A) -> str: ...
@overload
def f(x: B) -> int: ...

If a class exists that inherits from both ``A`` and ``B``, then type checkers could pick the wrong overload on a
call to ``f()``.

Type checkers could detect this source of unsafety and warn about it, but a correct implementation requires an understanding of solid bases,
because it relies on knowing whether values that are instances of both ``A`` and ``B`` can exist.
Although many type checkers already perform a version of this check for overlapping overloads, the typing specification does not
currently prescribe how this check should work. This PEP does not propose to change that, but it helps provide a building block for
a sound check for overlapping overloads.

Intersection types
------------------

Explicit intersection types, denoting a type that contains values that are instances of all of the
given types, are not currently part of the type system. They do, however, arise naturally in a set-theoretic type system
like Python's as a result of type narrowing, and future extensions to the type system may add support for explicit intersection types.

With intersection types, it is often important to know whether a particular intersection is inhabited, that is, whether
there are values that can be members of that intersection. This allows type checkers to understand reachability and
provide more precise type information to users.

As a concrete example, a possible implementation of assignability with intersection types could be that
given an intersection type ``A & B``, a type ``C`` is assignable to it if ``C`` is assignable to at least one of
``A`` and ``B``, and overlaps with all of ``A`` and ``B``. ("Overlaps" here means that there is at least one value
that is a member of both types; that is, ``A`` and ``B`` overlap if ``A & B`` is inhabited.) The second part of the
rule ensures that ``str`` is not assignable to a type like ``int & Any``: while ``str`` is assignable to ``Any``,
it does not overlap with ``int``. But of course, we can only know that ``str`` and ``int`` do not overlap if we know
that both classes are solid bases.

Overview
--------

Solid bases can be helpful in many corners of the type system. Though some of these corners are underspecified,
speculative, or of marginal importance, in each case the concept of solid bases enables type checkers to gain a more
precise understanding than the current type system allows. Thus, solid bases provide a firm foundation
(a solid base, if you will) for improving the Python type system.

Rationale
=========

The concept of "solid bases" enables type checkers to understand when a common child class of two classes can and cannot
exist. To communicate this concept to type checkers, we add an ``@solid_base`` decorator to the type system that marks
a class as a solid base. The semantics are roughly that a class cannot have two unrelated solid bases.

Runtime restrictions on multiple inheritance
--------------------------------------------

While Python generally allows multiple inheritance, the runtime imposes various restrictions, as documented in
`CPython PR 136844 <https://github.com/python/cpython/pull/136844/files>`__ (hopefully soon to be merged).
Two sets of restrictions, around a consistent MRO and a consistent metaclass, can already be implemented by
type checkers using information available in the type system. The third restriction, around instance layout,
is the one that requires knowledge of solid bases.

Alternative implementations of Python, such as PyPy, tend to behave similarly to CPython but may differ in details,
such as exactly which standard library classes are solid bases. As the type system does not currently contain any
explicit support for alternative Python implementations, this this PEP recommends that stub libraries such as typeshed
use CPython's behavior to determine when to use the ``@solid_base`` decorator. If future extensions to the type system
add support for alternative implementations (for example, branching on the value of ``sys.implementation``),
stubs could condition the presence of the ``@solid_base`` decorator on the implementation where necessary.

``@solid_base`` in implementation files
---------------------------------------

The most obvious use case for the ``@solid_base`` decorator will be in stub files for C libraries, such as the standard library,
for marking solid bases implemented in C.

However, there are also use cases for marking solid bases in implementation files, where the effect would be to disallow
the existence of child classes that inherit from the decorated class and another solid base, such as a standard library class
or another user class decorated with ``@solid_base``. For example, this could allow type checkers to flag code that can only
be reachable if a class exists that inherits from both a user class and a standard library class such as ``int`` or ``str``,
which may be technically possible but not practically plausible.

::

@solid_base
class BaseModel:
# ... General logic for model classes
pass

class Species(BaseModel):
name: str
# ... more fields

def process_species(species: Species):
if isinstance(species, str): # oops, forgot `.name`
pass # type checker should warn about this branch being unreachable
# BaseModel and str are solid bases, so a class that inherits from both cannot exist

This is similar in principle to the existing ``@final`` decorator, which also acts to restrict subclassing: in stubs, it
is used to mark classes that programmatically disallow subclassing, but in implementation files, it is often used to
indicate that a class is not intended to be subclassed, without runtime enforcement.

``@solid_base`` on special classes
----------------------------------

The ``@solid_base`` decorator is primarily intended for nominal classes, but the type system contains some other constructs that
syntactically use class definitions, so we have to consider whether the decorator should be allowed on them as well, and if so,
what it means.

For ``Protocol`` definitions, the most consistent interpretation would be that the only classes that can implement the
protocol would be classes that use nominal inheritance from the protocol, or ``@final`` classes that implement the protocol.
Other classes either have or could potentially have a solid base that is not the protocol. This is convoluted and not useful,
so we disallow ``@solid_base`` on ``Protocol`` definitions.

Similarly, the concept of a "solid base" is not meaningful on ``TypedDict`` definitions, as TypedDicts are purely structural types.

Although they receive some special treatment in the type system, ``NamedTuple`` definitions create real nominal classes that can
have child classes, so it makes sense to allow ``@solid_base`` on them and treat them like regular classes for the purposes
of the solid base mechanism.

Specification
=============

A decorator ``@typing.solid_base`` is added to the type system. It may only be used on nominal classes, including ``NamedTuple``
definitions; it is a type checker error to use the decorator on a function, ``TypedDict`` definition, or ``Protocol`` definition.

We define two properties on (nominal) classes: a class may or may not *be* a solid base, and every class must *have* a valid solid base.

A class is a solid base if it is decorated with ``@typing.solid_base``, or if it contains a non-empty ``__slots__`` definition.
The universal base class, ``object``, is also a solid base.

To determine a class's solid base, we look at all of its base classes to determine a set of candidate solid bases. For each base
that is itself a solid base, the candidate is the base itself; otherwise, it is the base's solid base. If the candidate set contains
a single solid base, that is the class's solid base. If there are multiple candidates, but one of them is a subclass of all other candidates,
that class is the solid base. If no such candidate exists, the class does not have a valid solid base, and therefore cannot exist.

Type checkers must check for a valid social base when checking class definitions, and emit a diagnostic if they encounter a class
definition that lacks a valid solid base. Type checkers must also use the solid base mechanism to determine whether types are disjoint,
for example when checking whether a type narrowing construct like ``isinstance()`` results in an unreachable branch.

Example::

from typing import solid_base, assert_never

@solid_base
class Solid1:
pass

@solid_base
class Solid2:
pass

@solid_base
class SolidChild(Solid1):
pass

class C1: # solid base is `object`
pass

# OK: candidate solid bases are `Solid1` and `object`, and `Solid1` is a subclass of `object`.
class C2(Solid1, C1): # solid base is `Solid1`
pass

# OK: candidate solid bases are `SolidChild` and `Solid1`, and `SolidChild` is a subclass of `Solid1`.
class C3(SolidChild, Solid1): # solid base is `SolidChild`
pass

class C4(Solid1, Solid2): # error: no single solid base
pass

def narrower(obj: Solid1) -> None:
if isinstance(obj, Solid2):
assert_never(obj) # OK: child class of `Solid1` and `Solid2` cannot exist
if isinstance(obj, C1):
reveal_type(obj) # Shows a non-empty type, e.g. `Solid1 & C1`

Runtime implementation
======================

A new decorator, ``@solid_base``, will be added to the ``typing`` module. Its runtime behavior (consistent with
similar decorators like ``@final``) is to set an attribute ``.__solid_base__ = True`` on the decorated object,
then return its argument::

def solid_base(cls):
cls.__solid_base__ = True
return cls

The ``__solid_base__`` attribute may be used for runtime introspection.

It will be useful to validate whether the ``@solid_base`` decorator should be applied in a stub. While
CPython does not document precisely which classes are solid bases, it is possible to replicate the behavior
of the interpreter using runtime introspection
(`example implementation <https://github.com/JelleZijlstra/pycroscope/blob/0d19236e4eda771175170a6b165b0e9f6a211d19/pycroscope/relations.py#L1469>`__).
Stub validation tools, such as mypy's ``stubtest``, could use this logic to check whether the
``@solid_base`` decorator is applied to the correct classes in stubs.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Practically speaking, I'm not sure the __solid_base__ attribute will tell you much other than that the class was explicitly decorated with the @solid_base decorator. No builtin class will set the attribute, most external C extensions won't either, and any class that defines non-empty slots will be an implicit solid base even if it doesn't use the decorator. A class might also have a metaclass that prevents setting attributes, so not even all classes explicitly decorated with @solid_base will necessarily have this attribute set (python/typing_extensions#634 (comment)).

If we want third-party tools to be able to introspect a class's solid base at runtime, a better solution would probably be to add an inspect.get_solid_base introspection helper that does something similar to your logic in pycroscope. I don't think it's essential to add that as part of the PEP, though it might be useful.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, but that doesn't seem too different from using the __final__ attribute: it doesn't actually stop you from creating subclasses, and there is no bool.__final__.

I think an inspect helper like that could be useful but don't feel a strong need for it myself.

Copy link
Member

@AlexWaygood AlexWaygood Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My vague worry is that people will misunderstand this attribute and think that it's a reliable way of determining whether a class is a solid base or not. Unlike @final, it feels pretty unlikely that we'll see ever much usage of this decorator in .py files, so I'd expect the vast majority of solid bases not to have this attribute. (Though to be clear, I agree that it makes sense to make it possible to apply the decorator to classes in .py files.)

Still, you're right that there are lots of @final classes that also don't have the __final__ attribute. I suppose it's fine to make solid_base introspectable whenever it is used in .py files, and I suppose the __solid_base__ attribute no more misleading than the __final__ attribute set by @final


Backward compatibility
======================

For compatibility with earlier versions of Python, the ``@solid_base`` decorator will be added to the
``typing_extensions`` backport package.

At runtime, the new decorator poses no compatibility issues.

In stubs, the decorator may be added to solid base classes even if not all type checkers understand the decorator yet;
such type checkers should simply treat the decorator as a no-op.

When type checkers add support for this PEP, users may see some changes in type checking behavior around reachability
and intersections. These changes should be positive, as they will better reflect the runtime behavior, and the scale of
user-visible changes is likely limited, similar to the normal amount of change between type checker versions. Type checkers
that are concerned about the impact of this change could use transition mechanisms such as opt-in flags.

Security Implications
=====================

None known.


How to Teach This
=================

Most users will not have to directly use or understand the ``@solid_base`` decorator, as the expectation is that will be
primarily used in library stubs for low-level libraries. Teachers of Python can introduce
the concept of "solid bases" to explain why multiple inheritance is not allowed in certain cases. Teachers of
Python typing can introduce the decorator when teaching type narrowing constructs like ``isinstance()`` to
explain to users why type checkers treat certain branches as unreachable.

Reference Implementation
========================

None yet.


Copyright
=========

This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.