Skip to content

copy.replace type inference regression: breaks Self-returning __replace__ with bound TypeVar #21672

Open

Description

@trim21

Bug Report

typeshed PR python/typeshed#14786 (synced into mypy) changed copy.replace from a bound-TypeVar signature to a covariant protocol. This breaks copy.replace for __replace__ methods that return Self (dataclasses, namedtuples, etc.) when the argument is a bounded TypeVar.

To Reproduce

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

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

Expected Behavior

assert_type(result, T) should pass — copy.replace should return the same type as the argument.

Actual Behavior

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

mypy resolves _RT_co from _SupportsReplace.__replace__ return type, which for a Self-returning method resolves to the upper bound BaseConfig, not T.

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

Root Cause

typeshed PR python/typeshed#14786 changed copy.pyi 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 preserved the argument type; the covariant protocol loses it because _RT_co is inferred from the protocol method return type which, for Self, resolves at the upper bound level.

Note

The motivation for python/typeshed#14786 was mypy issue #19694 which is about dataclasses.replace (not copy.replace), so the PR did not actually address its intended target.

Your Environment

  • Mypy version used: 2.2.0+dev
  • Mypy command-line flags: --python-version 3.13
  • Python version used: 3.13+

Activity

  1. changed the title [-]copy.replace type inference regression: breaks Self-returning __replace__ with bound TypeVar[/-] [+]`copy.replace` type inference regression: breaks Self-returning __replace__ with bound TypeVar[/+] on Jul 3, 2026
  2. trim21 commented on Aug 9, 2026

    @trim21
    Author
  3. JPHutchins commented on Oct 21, 2026

    @JPHutchins

    Warning

    LLM Disclosure

    This post was authored by claude-opus-5-5 on behalf of @JPHutchins. While working out JPHutchins/salix#359, JP asked me to report the upstream causes with evidence; this adds the union cases to this issue.

    The union siblings of this issue: in mypy 2.4.0, copy.replace on a union of dataclasses returns object, and so does a TypeVar bound to a union. That's a join, which is wider than this issue's bound. pyright 1.1.414 and ty 0.0.90 keep the argument's type in both cases.

    import copy
    from dataclasses import dataclass
    from typing import TypeVar
    
    
    @dataclass(frozen=True)
    class A:
        x: int
    
    
    @dataclass(frozen=True)
    class B:
        x: int
    
    
    T = TypeVar("T", bound="A | B")
    
    
    def bump(value: A | B) -> A | B:
        return copy.replace(value, x=1)  # error: got "object", expected "A | B"
    
    
    def bump_bound(value: T) -> T:
        return copy.replace(value, x=1)  # error: got "object", expected "T"
    Measurements and the mypy issues behind them
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions