Typing feature to statically get all subclasses of class

I sometimes find myself with this problem:


class Base:  # Possibly an ABC
    ...

class SubA(Base):
    ...

class SubB(Base):
    ...

For example with custom exceptions, base ast nodes and so on.

Now I often want to use exhaustive pattern matching while excluding the base class, since it is often abstract.

In this case, I often find myself missing some way to describe “all subclasses of a type”. In this case, I would usually have to do:


... # Code from before

type AllSubclassesT = SubA | SubB

# And then I can exhaustively match:

def func_that_handles_all(sub: AllSubclassesT) -> Any:
    match sub:
        case SubA():
            ...
        case SubB():
            ...
        case _:
            assert_never(sub)

If I add another subclass to my possibly long list of subclasses, I could easily forget it in the type alias and therefore my type checker won’t help me with handling all cases.

So for this case, a typing solution could be useful. It would scan the whole code for any class that subclass Base and then give you that statically as a union. Obviously, this wouldn’t work for dynamic class creation, but that is limited for static typing in many cases either way and not the main use case for this.

So basically:


...

from typing import SubclassesOf

type AllSubclassesT = SubclassesOf[Base]  # SubA | SubB

Returning a tuple instead of a union is also an idea I had, but I’m pretty sure unions make all things possibly that tuples would make possible, but not vice versa.

The result of this new type could not be a tuple of any specific order, for example tuple[SubA, SubB] since the type checker CANNOT know the order of subclasses which this would most likely be based off and therefore it would be ambiguous. Therefore tuple[SubA | SubB, ...] is the best type hint we can produce. But we can just do tuple[SubclassesOf[Base], ...] of the union result version and have this case included. Going from a tuple to union is (I think) not possible currently.

What do you think? Do you think this is feasible (especially to scan all files for classes or another idea)?

I just wanted to share the idea since I had this problem before.

Oh yeah I also didn’t mention recursive subclasses in this post yet, that would be another issue to solve. I think the base idea should be tackled first though.

I’m afraid this won’t work because Python’s type system is isn’t closed-world, so type-checkers can’t reliably determine all of the subclasses that exist. One way around this could be by adding support for sealed classes from e.g. Draft PEP: Sealed decorator for static typing, but this proposal seems to have stranded.

3 Likes

Makes sense, but this may be a design error. Why not make the function into an abstract method and put the code for each subclass into its derived method?

I don’t do this for subclasses, but I have a pattern you can use:

  1. create an exhaustive list somewhere of all options
  2. in your CI/DI pipeline, assert that the list is exhaustive

To me using unit tests is the most natural place to put that assert. But I think if you really wanted to you might be able to make static type checking do it somehow.

To me the most “natural” way to achieve this[1] is to use a metaclass, to assert that the class is a member of AllSubClassesT.__args__
Then your code fails at import time if you define the subclass without updating the union.


  1. making your CI/DI pipeline pick up on it that you’ve forgotten to update your exhaustive list ↩︎

Actually, I believe you can “just” use __init_subclass__

I am not trying to argue against your proposal, but if you aren’t aware, you can always do a runtime check:

assert not (x := (
    set(Base.__subclasses__())
  - set(AllSubclassesT.evaluate_value().__args__)
)), x

I ran into a similar issue (although not with sub-classes, but all NamedTuple’s defined in a particular module).

2 Likes