2025, Oct 19 03:00

Why Union-Bounded Python Generics Fail in Pylance (and How Constrained Type Parameters Fix Them)

Learn why union-bounded Python generics break type safety and trigger Pylance errors, and how constrained type parameters fix method calls in Pyright and mypy.

When a generic class is parameterized by a union, type relationships that look obvious at first glance often fall apart under a type checker. A common case is a method where both self and other are the same generic instantiation, yet a field-access inside the method fails type checking. Below is a minimal setup that triggers this with Pylance in Python 3.13.3.

Problem setup

from typing import Self
class PartA:
    m: int
    def merge(self, peer: Self) -> None:
        self.m += peer.m
class PartB:
    s: str
    def merge(self, peer: Self) -> None:
        self.s += peer.s
PartMix = PartA | PartB
class Holder[PieceT: PartMix]:
    piece: PieceT
    def join(self, peer_box: Self) -> None:
        self.piece.merge(peer_box.piece)  # Pylance flags this line

Pylance reports that the argument type of peer_box.piece is too wide for merge. The message looks like this:

Argument of type "PieceT@Holder" cannot be assigned to parameter "peer" of type "PartA*" in function "merge". Type "PartA* | PartB*" is not assignable to type "PartA*". "PartB*" is not assignable to "PartA*". Similarly for "PartB*".

Why it happens

Inside join, both self and peer_box are the same Holder[PieceT]. Intuitively, you might expect self.piece and peer_box.piece to share the same concrete type. However, because PieceT is bounded by a union, Pylance keeps peer_box.piece as PartMix and cannot prove that it matches the specific branch required by merge at the call site.

More importantly, allowing a union as the upper bound of the type parameter makes the generic itself admit an unsafe instantiation. A Holder[PartMix] is valid under that definition, and that breaks the intended contract. Consider the following sequence, which type checks but fails at runtime because the concrete pieces do not match:

mix_a: PartMix = PartA()
box_a: Holder[PartMix] = Holder(mix_a)
mix_b: PartMix = PartB()
box_b: Holder[PartMix] = Holder(mix_b)
box_a.join(box_b)  # both are Holder[PartMix], but pieces are incompatible at runtime

This shows why the type checker refuses to accept the merge call: it would have to accept a scenario that can’t be made safe. Using typing.cast here doesn’t resolve the issue either, since the fundamental problem is the permissive bound that admits Holder[PartMix].

(Assuming piece is intended to be an instance attribute set via the constructor rather than a class attribute.)

Safe fix with constrained generics

Rather than using a union as an upper bound, constrain the type parameter to a fixed set of alternatives. This preserves the intended variability—Holder can be specialized with PartA or PartB—while preventing the unsafe Holder[PartA | PartB] instantiation.

class Holder[PieceT: (PartA, PartB)]:
    piece: PieceT
    def join(self, peer_box: Self) -> None:
        self.piece.merge(peer_box.piece)

With this definition, Holder[PartA] and Holder[PartB] are both allowed, but Holder[PartA | PartB] is not. The join call now type checks and aligns with runtime behavior. This passes both Pyright and mypy.

Why this matters

Union-typed bounds in generics can look convenient but often encode ambiguity into the generic itself. That ambiguity leaks into methods relying on Self-like relationships and forces the type checker to keep members as a union, preventing valid calls from being proven safe. Worse, it admits generic instantiations that are unsound and can crash at runtime. Constraining the type parameter to an explicit set removes that ambiguity without sacrificing expressiveness.

Conclusion

If a generic must support several concrete implementations, do not use a union as the type parameter’s bound. Use constraints on the type parameter so the generic can be instantiated only with a single compatible alternative at a time. This keeps method bodies type-safe, prevents accidental Holder[PartA | PartB] instantiations, and aligns the checker’s guarantees with runtime behavior.

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