Skip to content

copy.replace: _SupportsReplace[RT] breaks Self-returning __replace__ with bound TypeVar #15973

Description

@trim21

PR #14786 changed copy.replace from a bound-TypeVar signature to a covariant protocol. This introduced a regression for __replace__ methods that return Self (dataclasses, namedtuples, etc.) when the argument is a bounded TypeVar.

To Reproduce

import copy
from dataclasses import dataclass
from typing import TypeVar, assert_type

@dataclass(frozen=True)
class BaseConfig:
    pg_ssl_key: str | None = None

@dataclass(frozen=True)
class SubConfig(BaseConfig):
    pg_host: str = "localhost"

T = TypeVar("T", bound=BaseConfig)

def f(config: T) -> T:
    result = copy.replace(config, pg_ssl_key="replaced")
    assert_type(result, T)
    return result

Playground: https://mypy-play.net/?mypy=master&python=3.14&gist=32a94d730b07faeb680192a3fdad1b0f

Expected

assert_type(result, T) passes — copy.replace returns the same type as the argument.

Actual

error: Expression is of type "BaseConfig", not "T"  [assert-type]
error: Incompatible return value type (got "BaseConfig", expected "T")  [return-value]

Concrete subclass works fine — copy.replace(SubConfig(), ...) correctly returns SubConfig. Only the TypeVar-bounded case breaks.

Root Cause

The change from:

_SR = TypeVar("_SR", bound=_SupportsReplace)
class _SupportsReplace(Protocol):
    def __replace__(self, ...) -> Self: ...
def replace(obj: _SR, /, ...) -> _SR: ...

to:

_RT_co = TypeVar("_RT_co", covariant=True)
class _SupportsReplace(Protocol[_RT_co]):
    def __replace__(self, ...) -> _RT_co: ...
def replace(obj: _SupportsReplace[_RT_co], /, ...) -> _RT_co: ...

The bound-TypeVar approach preserves the argument type through the call. The covariant protocol infers _RT_co from the protocol method return type, which for Self resolves at the upper bound level — losing the TypeVar.

This is a fundamental limitation of Python's structural subtyping: one signature cannot simultaneously express both "return same type as argument" (the common Self case) and "return whatever __replace__ declares" (the Box[int] -> Box[str] edge case).

The common case (dataclasses / namedtuples returning Self) should take priority, since the edge case can use typing.cast as a workaround but the Self case has no workaround.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions