2025, Sep 25 13:00

Type-Safe Registry: Map Python subclasses to their instances with generics, mypy and Pyright

Learn how to type a subclass-to-instance registry in Python. Use a typed accessor, generics and a safe cast that satisfies mypy and Pyright for precise APIs.

When a codebase keeps a single mapping from classes to instances of those classes, it’s natural to want a method that returns “the instance matching the class I pass in” and to have the type checker prove that. The challenge is to express this relationship to static analysis, so that asking for a subclass yields an instance of that precise subclass, not just the base type.

Problem setup

Consider a small inheritance hierarchy and a registry that conceptually maps subclasses to their instances. The intuitive type hint is a dictionary keyed by subclasses with values of the matching instance type. Here’s what that often looks like at first draft:

from typing import TypeVar
class Root:
    ...
class KindOne(Root):
    ...
class KindTwo(Root):
    ...
class Registry:
    U = TypeVar('U', bound=Root)
    entries: dict[type[U], U] = {}
    def fetch(self, cls: type[U]) -> U:
        return self.entries[cls]

The intention is clear: a dict maps KindOne to KindOne(), and KindTwo to KindTwo(). The method fetch should return the exact type asked for, not just Root.

What’s really going on

A direct dict type hint tying type[U] to U reads well, but it doesn’t play nicely with static checkers. The relationship “for each key that is a subtype, the corresponding value is an instance of that same subtype” is per-key, not uniform across the entire mapping. Dict type parameters describe all entries uniformly, and most checkers will not track this key–value dependency across arbitrary lookups.

If the goal is to expose only a get-style entrypoint and keep the underlying storage private, there’s a pragmatic path. The registry can store values using the base class for both keys and values, while the public method performs a targeted cast to the requested subtype. To users of the API, the result is a precise and clean return type; internally, the mapping remains simple and the cast is the small bridge that expresses intent.

Working approach with a typed entrypoint

The following pattern works with popular static checkers such as mypy and Pyright, while keeping the implementation straightforward:

from typing import cast, reveal_type
class Root:
    ...
class KindOne(Root):
    ...
class KindTwo(Root):
    ...
class Registry:
    _store: dict[type[Root], Root] = {KindOne: KindOne(), KindTwo: KindTwo()}
    def fetch[T: Root](self, cls: type[T]) -> T:
        return cast(T, self._store[cls])
reg = Registry()
reveal_type(reg.fetch(KindOne))  # KindOne
reveal_type(reg.fetch(KindTwo))  # KindTwo

The mapping is kept behind the scenes as dict[type[Root], Root]. The public method fetch carries the precise type relationship via its type parameter and uses cast to reflect the fact that, by construction, the stored instance matches the requested class.

This approach is intentionally minimal. One could design a custom mapping abstraction to encode the constraint, but there isn’t a strong reason to go beyond a private dict and a single well-typed accessor.

Why it’s important

Precisely typed APIs reduce accidental misuse in client code. When the caller passes KindOne, they get KindOne back, and the type checker confirms it. That means fewer downcasts, fewer ignores, and clearer contracts at the call site.

At the same time, it’s worth keeping expectations grounded. The cast expresses intent and helps callers, but it doesn’t make the checker verify that the private mapping always respects the key–value pairing. If the internal storage stops aligning with that invariant, the checker won’t complain about the cast by itself. In other words, this pattern protects clients from mistakes, but it does not automatically validate your internal mapping logic.

Conclusion

To model a dict from subclasses to instances of those subclasses, keep the storage private and lean on a strongly typed accessor. Store entries as dict[type[Root], Root], and implement a fetch method parameterized by the base type that returns the specific subtype through a narrow cast. This keeps the API surface clean and precise for users, while the internal invariant remains your responsibility to uphold.

The article is based on a question from StackOverflow by bossi and an answer by Anerdw.