2025, Nov 20 11:00

Preserving subclass constructor signatures in Python 3.12 factories using ParamSpec, Concatenate, and Callable

Learn how to type a Python 3.12 factory that preserves subclass constructor signatures using ParamSpec, Concatenate, and Callable; works with mypy and pyright.

Typing a factory in Python 3.12 so it preserves the constructor signature of subclasses looks straightforward until you try to make your IDE and type checker happy. The common pattern of partially applying constructor arguments works at runtime, but without careful typing the signature information is lost and IntelliSense degrades. Below is a compact walkthrough of why that happens and how to restore precise signatures with ParamSpec and Concatenate.

Problem statement

We want a classmethod that captures some constructor arguments up front, returns a callable, and later completes the instantiation by providing the first missing parameter. The runtime behavior is fine, but the signature of the returned callable should reflect the subclass constructor minus that first parameter so editors and type checkers can guide usage.

from typing import ParamSpec, TypeVar

Q = ParamSpec("Q")
U = TypeVar("U")

class Root:
    @classmethod
    def make(cls, *args: Q.args, **kwargs: Q.kwargs):
        def binder(anchor):
            return cls(anchor, *args, **kwargs)
        return binder

class VariantA(Root):
    def __init__(self, anchor: str, x1: int, x2: int):
        print(f"VariantA created with: {anchor}, {x1}, {x2}")

stub = VariantA.make(1, 2)
print(stub)
obj = stub("ctx")
print(obj)

The factory works, the object is created, but the callable returned by make doesn’t advertise the signature derived from VariantA.__init__ without the first parameter. Autocomplete doesn’t help and static analysis can’t verify calls.

Why the signature is lost

ParamSpec tracks callable parameter lists, but in the code above it isn’t tied to the constructor type of cls. The type checker sees cls as a class object, not as a callable with a concrete parameter list. To preserve the signature we must do two things. First, reinterpret the current class cls as a Callable whose parameters match its constructor. In typing terms, type[Something] behaves like the type of its constructor, so that reinterpretation is valid for typing purposes. Second, remove the first argument from that signature to model partial application. Concatenate lets us express “one leading parameter plus the rest,” which we can then split into the captured arguments and the argument required later.

The fix with Concatenate and Callable

Binding ParamSpec to the constructor and extracting the first parameter solves the problem. The result type of the factory is a callable that accepts only the first argument, while the factory itself accepts the rest of the constructor parameters. This approach type-checks cleanly with mypy. For pyright, define a minimal __init__ on the base class with at least one argument so the Callable with a leading parameter remains compatible.

from typing import Callable, Concatenate, ParamSpec, TypeVar

Q = ParamSpec("Q")
U = TypeVar("U")
V = TypeVar("V")

class Root:
    def __init__(self, anchor: str) -> None:
        pass

    @classmethod
    def make(
        cls: Callable[Concatenate[U, Q], V],
        *args: Q.args,
        **kwargs: Q.kwargs,
    ) -> Callable[[U], V]:
        def binder(anchor: U) -> V:
            return cls(anchor, *args, **kwargs)
        return binder

class VariantA(Root):
    def __init__(self, anchor: str, x1: int, x2: int) -> None:
        print(f"VariantA created with: {anchor}, {x1}, {x2}")

stub = VariantA.make(1, 2)
print(stub)
obj = stub("ctx")
print(obj)

This rebinds the class to Callable[Concatenate[U, Q], V], where U is the first constructor parameter (the one supplied later) and Q is the rest (captured by make). The factory returns Callable[[U], V], a function that accepts the first parameter and produces the constructed object. mypy accepts this arrangement, and pyright aligns when the base class defines an __init__ with at least one parameter.

Why this matters

Correct typing here isn’t just a nicety. Accurate signatures drive IDE completion, reduce guesswork, and tighten feedback loops in reviews and CI. With ParamSpec and Concatenate, a factory pattern that partially applies constructor arguments remains discoverable and verifiable. Teams benefit from consistent, predictable editor help and clearer error messages from type checkers.

Takeaways

If a factory must preserve constructor signatures while deferring one leading argument, model the class as a Callable and use Concatenate to separate the first parameter from the remainder. Bind ParamSpec to the tail of the constructor, return a callable for the head parameter, and keep a minimal __init__ on the base to keep pyright satisfied. This keeps your runtime ergonomics and your static guarantees aligned.